> ## Documentation Index
> Fetch the complete documentation index at: https://docs.shftd2.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Management API

> Create and update sites, fragments, routing rules, card eligibility and API keys from a script, with an organisation token, on api.shftd2.com/v1.

The management API lets a script write what you otherwise set up in the console: sites, fragments, the site
default, routing rules, the fragments each card is eligible on (its eligibility), and API keys. It applies the
same rules and returns the same error messages as the console forms. Use it to seed many sites at once — one
fragment per ad-unit family, one rule per section — and to keep them in sync.

It is separate from the [fragment endpoint](/reference#fragment-endpoint) that serves cards: it takes another
credential, and it never serves a card. Every endpoint has its own page under **Endpoints**, generated from the
OpenAPI description [`openapi-management.json`](/openapi-management.json).

## Authentication and tokens

Every call sends a **management token** in the `Authorization` header:

```http theme={"theme":"css-variables"}
GET https://api.shftd2.com/v1/me
Authorization: Bearer ds_mgmt_…
```

* An **owner** of the organisation creates the token in the console, in **Settings → Management tokens**. The
  token is shown once, when it is created: copy it then. The console then lists its name, its prefix, who created
  it, its expiry and when it was last used.
* A token acts on the **whole organisation**: every site, fragment, card and API key. There are no finer rights.
* Revoke it there as soon as the script is done, or at once if it may have leaked: the next call answers `401`.
  A token created by an owner does not expire; one created by doubleshift support expires after 90 days.
* A management token (`ds_mgmt_…`) is not an [API key](/api-keys) (`ds_live_…`). The fragment endpoint refuses
  a management token, and the management API refuses an API key.
* The API exists on `api.shftd2.com` only: `app.shftd2.com` answers `404` on these paths. Cookies are never read,
  so being signed in to the console changes nothing. A call without `Authorization: Bearer` is a `401` with
  `WWW-Authenticate: Bearer realm="doubleshift"`.

Keep the token out of your shell history and out of files. In a terminal:

```bash theme={"theme":"css-variables"}
read -rs DS_MANAGEMENT_TOKEN && export DS_MANAGEMENT_TOKEN   # paste the token, then press Enter
export API=https://api.shftd2.com/v1
```

Then check which organisation the token writes to. This is always the first call of a script:

```bash theme={"theme":"css-variables"}
curl -sS "$API/me" -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN"
```

```json theme={"theme":"css-variables"}
{ "account": { "id": "acc_example1", "name": "Example Media" }, "token": { "id": "mgt_exampletok01", "prefix": "ds_mgmt_9f8e" } }
```

`account.id` is also your `partner_id` on the fragment endpoint.

## Limits and errors

Every `PUT`, `POST` and `DELETE` sends `Content-Type: application/json` and a JSON object — `{}` for a
`DELETE`. The body is at most 262 144 bytes, counted before it is parsed. A field the endpoint does not know is
refused, nested ones included: send only the documented fields.

Every response is JSON with `Cache-Control: private, no-store`. Lists are `{"items": [...]}` and complete: there
is no pagination. Errors are `{"error": {"code", "message", "field"?}}`. Branch on `code` and `field`; `message`
is the console's wording, meant for people.

| Status | `error.code`                                                                | When                                                                                                                                   |
| ------ | --------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `unsupported_media_type`, `body_too_large`, `invalid_json`, `unknown_field` | The body is not declared as JSON, is too large, cannot be parsed, or carries a field the endpoint does not know (`field` names it).    |
| `401`  | `missing_token`, `invalid_token`                                            | No `Authorization: Bearer`, or not a live management token: malformed, unknown, revoked or expired.                                    |
| `403`  | `platform_card`                                                             | `PUT /v1/cards/{id}/eligibility` on a platform card.                                                                                   |
| `404`  | `not_found`                                                                 | The site, rule, card or key does not exist **in your organisation**. Another organisation's is the same answer.                        |
| `409`  | `conflict`                                                                  | `POST …/routes`: the same rule already exists. Rarely, a write that collided with another one on the same object: read it, then retry. |
| `422`  | `validation`                                                                | A value is refused; `field` names it: `name`, `ref`, `routes[3].path_prefix`, `fragments[0].domain`, …                                 |
| `429`  | `rate_limited`                                                              | A limit is reached. `Retry-After: 60`, and `"retry_after": 60` in the body. Nothing was written.                                       |
| `500`  | `internal`                                                                  | An unexpected error. Read the object again before you retry a write.                                                                   |

The checks run in this order, and nothing is written until they all pass: the token and the limits, then the
body (`400`, then `422` on its shape), then the site, card or key the path names (`404`), then what depends on
what is stored (`403`, `409`, `422`). So an invalid fragment body sent to an unknown site is a `422`, not a
`404`.

Limits:

* Per token: 120 requests per minute, of which at most 60 writes (`PUT`, `POST`, `DELETE`).
* Per IP address: 30 failed authentications per minute, and 600 requests per minute that present a well-formed
  token.
* Every response carries `RateLimit-Policy: "token";q=120;w=60, "write";q=60;w=60`. No header tells you how many
  calls remain.
* The counters are kept per Cloudflare location, so the limits are approximate. Space your writes — one per
  second stays well within them — and on a `429`, wait for `Retry-After` before you retry.

## References

A fragment is addressed by its site's domain and its **reference**: the GAM ad-unit path of the inventory
without its format, e.g. `/1234567/news-site/organic/politics` (see [fragments](/fragments)).

* `{domain}` is the site's bare domain: lowercase, without scheme, path or port. `www.news-site.example` and
  `news-site.example` are two different sites.
* A reference is case-sensitive: `/Politics` and `/politics` are two fragments. It is 1 to 200 characters among
  `A-Z a-z 0-9 / _ - . :`, and no path segment may be `.` or `..` on its own: URL processing removes such
  segments, so the reference could not be addressed. A dot inside a segment is fine: `.name`, `a.b`,
  `/v1.2/politics`.
* In a **path**, encode the whole reference once with `encodeURIComponent`, slashes included, so it travels as
  one segment:

  ```bash theme={"theme":"css-variables"}
  export DOMAIN=news-site.example
  export REF='/1234567/news-site/organic/politics'
  export REF_PATH=$(node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$REF")
  echo "$REF_PATH"   # %2F1234567%2Fnews-site%2Forganic%2Fpolitics
  ```

  Encode it once, never twice: a double-encoded reference (`%252F…`) is refused with `422`, and unencoded
  slashes match no endpoint (`404`).
* In a JSON **body** — `ref`, `default_fragment_ref`, `routes[].ref`, `fragments[].ref` — send the reference as
  a plain string, never encoded.
* A fragment created in the console without a reference is listed with `"ref": null`, but the API cannot write
  it, make it the default, target it with a rule or make a card eligible on it. Give it a reference in the console
  first.
* A reference cannot be changed through the API: it is the fragment's address.

## Writes and idempotency

* `PUT` on a site or a fragment **creates or updates** the object at that address: `201` the first time, `200`
  afterwards. Sending the same body again leaves the same state, so a script can run twice.
* `PUT …/routes` and `PUT …/eligibility` **replace the whole list**. Each replacement is checked entirely, then
  applied at once: on any error nothing changes. But there is no transaction across calls, and nothing protects
  a replacement from another client writing the same list at the same time: the last write wins. Read the list
  just before you replace it, and never run two scripts on the same organisation at once.
* An empty list is destructive: `{"routes": []}` deletes every rule of the site, and `{"fragments": []}` makes
  your card, when active, eligible on every fragment of your organisation.
* `PUT …/routes` gives every rule a new `id`. Do not keep rule ids across a replacement.
* Deleting a rule is not repeatable: a second `DELETE` of the same id is a `404`. Clearing the site default and
  revoking a key are repeatable: the second call answers `200` with the same state.
* `POST …/routes` adds one rule and answers `409` if it already exists.
* `POST /v1/keys` is not repeatable: each call creates a new key (see [Delivery keys](#delivery-keys)).

## Sites and fragments

**Sites.** `PUT /v1/sites/{domain}` registers the site in your organisation, or updates it.

* `name` absent keeps the current name; `""` clears it.
* `default_fragment_ref` absent keeps the current default. When present, it must be an **active** fragment of this
  site — so it cannot be set in the call that creates the site (see
  [Defaults and routing rules](#defaults-and-routing-rules)).
* `domain` in the body is refused: the domain is the address, and it never changes. To move to another domain,
  register it as a new site.
* The site is created in your organisation even if another organisation has registered the same domain.
* A site created through the API has no icon until you first open it in the console.

```bash theme={"theme":"css-variables"}
curl -sS "$API/sites" -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN"

curl -sS -X PUT "$API/sites/$DOMAIN" \
  -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "News site"}'
```

**Fragments.** `PUT /v1/sites/{domain}/fragments/{ref}` creates or updates the fragment with that reference.
The body describes the whole fragment:

* `name` is required.
* `lang` is `fr`, `en`, `de`, `es` or `it`. Absent, it is `fr` — on an update too.
* `description` absent or empty: none.
* `key_values` absent: `{}`. Otherwise an object of non-empty lists of non-empty strings,
  `{"section": ["politics"]}`: keys in `a-z 0-9 _ -` (the key `__proto__` is refused), values trimmed and without
  commas or line breaks. Key-values are legacy targeting (see [targeting](/targeting)).
* `status` is `active` or `paused`. Absent: `active` on creation, unchanged on update.

<Warning>
  **On an update, a field you leave out is reset, not kept**: `lang` goes back to `fr`, the description is
  removed and `key_values` becomes `{}`. Only `status` is kept. Send every field every time.
</Warning>

Pausing a fragment keeps the site default and the rules that point to it: their pages answer `204`. The
response says so in `warnings`, for example `["site default", "target of 2 rules"]`; otherwise `warnings` is
`[]`.

```bash theme={"theme":"css-variables"}
curl -sS "$API/sites/$DOMAIN/fragments" -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN"

curl -sS -X PUT "$API/sites/$DOMAIN/fragments/$REF_PATH" \
  -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "Politics", "lang": "en", "key_values": {"section": ["politics"]}}'
```

Sites are listed by domain; fragments by reference, those without one last.

## Defaults and routing rules

These decide which fragment a page resolves to on `GET /v1/fragment`: an exact rule, then the longest prefix
rule, then the site default (see [sites](/sites#default-fragment-and-routing-rules)).

**Default.** `PUT /v1/sites/{domain}/default-fragment` with `{"ref": …}` sets it: an **active** fragment of this
site. `DELETE` with `{}` removes it; the fragment itself is kept. A default that is paused afterwards stays the
default, and its pages answer `204`; but a paused fragment cannot be newly set as default. To reproduce that
state, write the fragment active, set the default, then pause the fragment.

```bash theme={"theme":"css-variables"}
curl -sS -X PUT "$API/sites/$DOMAIN/default-fragment" \
  -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"ref\": \"$REF\"}"

curl -sS -X DELETE "$API/sites/$DOMAIN/default-fragment" \
  -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

**Routing rules.** They follow the console's rules: `match` is `exact` or `prefix`; the path is stored lowercase
with its canonical encoding (`Actualite/` becomes `/actualite/`), 512 characters at most as sent (trimmed) and
200 once canonical; `ref` names a fragment of this site, a paused one included. A site has at most 200 rules, listed by
path then match mode.

* `PUT …/routes` with `{"routes": [{"path_prefix", "match", "ref"}]}` replaces them all.
* `POST …/routes` with `{"path_prefix", "match", "ref"}` adds one: `409` if the same path and match mode already
  exist, `422` once the site has 200 rules.
* `DELETE …/routes/{id}` with `{}` removes one.

<Warning>
  **`PUT …/routes` replaces every rule of the site.** Rules missing from the list are deleted, and
  `{"routes": []}` deletes them all. Read the current rules first. The list is checked entirely — ceiling,
  paths, references, duplicates after canonicalization — then applied at once: on any error nothing changes.
</Warning>

```bash theme={"theme":"css-variables"}
curl -sS "$API/sites/$DOMAIN/routes" -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN"

curl -sS -X PUT "$API/sites/$DOMAIN/routes" \
  -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"routes\": [{\"path_prefix\": \"/politics/\", \"match\": \"prefix\", \"ref\": \"$REF\"}]}"

curl -sS -X POST "$API/sites/$DOMAIN/routes" \
  -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"path_prefix\": \"/politics/live/\", \"match\": \"exact\", \"ref\": \"$REF\"}"
```

To delete one rule, set `RULE_ID` to an `id` from `GET …/routes`:

```bash theme={"theme":"css-variables"}
curl -sS -X DELETE "$API/sites/$DOMAIN/routes/$RULE_ID" \
  -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Cards and eligibility

Cards are created, edited and activated in the console. The API lists them and sets the fragments each card is
eligible on — its eligibility (see [eligibility](/cards#eligibility-where-a-card-may-serve)). Several cards can be
eligible on the same fragment; doubleshift then picks one per page.

* `GET /v1/cards` lists your organisation's cards and the platform cards (`"account_id": null`), in every status
  (`draft`, `active`, `archived`), by id.
* `GET /v1/cards/{id}/eligibility` lists the fragments of your organisation the card is eligible on. For one of
  your cards, an empty list (`[]`) means it is eligible everywhere in the organisation.
* `PUT /v1/cards/{id}/eligibility` with `{"fragments": [{"domain", "ref"}]}` sets that list. Each fragment is named
  by its site's domain and its reference, as a plain string. Only your organisation's fragments are accepted — a
  paused one too — and a card in any status can be made eligible. The same fragment named twice counts once.

<Warning>
  **`PUT …/eligibility` replaces the card's eligibility in your organisation.** Fragments missing from the list
  lose the card, and `{"fragments": []}` empties the list: your card, when active, is then eligible everywhere in
  the organisation. Read the current list first. The list is checked entirely, then applied at once.
</Warning>

Platform cards are managed by doubleshift. You can read them and their eligibility on your fragments, but
`PUT …/eligibility` answers `403` `platform_card`. For a platform card an empty list is not conclusive: the
eligibility other organisations set is never shown, so the card may be eligible everywhere, or only on their
fragments.

Set `CARD_ID` to an `id` from `GET /v1/cards`:

```bash theme={"theme":"css-variables"}
curl -sS "$API/cards" -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN"

curl -sS "$API/cards/$CARD_ID/eligibility" -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN"

curl -sS -X PUT "$API/cards/$CARD_ID/eligibility" \
  -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"fragments\": [{\"domain\": \"$DOMAIN\", \"ref\": \"$REF\"}]}"
```

## Delivery keys

Delivery keys are the [API keys](/api-keys) your edge sends to the fragment endpoint.

* `POST /v1/keys` with an optional `{"name"}` creates one, and answers `201` with the full key in `key`:
  `ds_live_` followed by 40 hexadecimal characters. **The key is in that response only**: store it at once in
  your secret store. Afterwards only its 12-character `prefix` is listed.
* A `POST` is not repeatable: each call creates a new key. If a response is lost, do not retry blindly: list the
  keys, revoke the one you never received, then create another.
* `GET /v1/keys` lists every key, revoked ones included, newest first. It never shows the full key.
* `DELETE /v1/keys/{id}` with `{}` revokes a key: the fragment endpoint refuses it from the next call. Revoking
  it again answers `200` with the first revocation date. A revoked key is deleted in the console.

The `POST` response holds a secret: keep it out of logs and tickets. Set `KEY_ID` to an `id` from
`GET /v1/keys` to revoke that key:

```bash theme={"theme":"css-variables"}
curl -sS "$API/keys" -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN"

curl -sS -X POST "$API/keys" \
  -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "CDN"}'

curl -sS -X DELETE "$API/keys/$KEY_ID" \
  -H "Authorization: Bearer $DS_MANAGEMENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
```

## Prepare a client

A script that seeds or syncs an organisation:

1. Reads the token from its environment — never from an argument or a file — and never prints it.
2. Calls `GET /v1/me` first, and stops unless `account.id` is the organisation it means to write to.
3. Reads before it writes: `GET` the object, compare it with the wanted state, and write only what differs.
   Compare paths and domains in their stored form (lowercase), references exactly.
4. Writes in this order, site by site: the site, its fragments, the default, the rules. Card eligibility comes
   last, once every site is done, because it names fragments the earlier steps create — and not at all if an
   earlier step failed. When the wanted state lives in a manifest, keep eligibility in its own section — for
   example `"eligibility": [{"advertiser_id", "fragments": [{"domain", "ref"}]}]` — with one complete list per
   card.
5. Spaces its writes, about one per second, and on a `429` waits for `Retry-After`.
6. Leaves keys and cards aside. It creates an API key only when one is needed, in a separate step, and keeps the
   secret the response returns. Cards must already exist: they are created in the console.
7. Runs twice in a row with the second run changing nothing.
8. Ends with the token revoked in **Settings → Management tokens**.

<Note>
  This documentation has an MCP server, `https://docs.shftd2.com/mcp`, that lets an AI assistant search these
  pages. It does not call the management API.
</Note>

## Console-only actions

The API does not do these; the console does.

* Creating, editing, activating, archiving or deleting a card: the console's editorial flow.
* Deleting a site or a fragment, which removes what depends on it. The console asks for confirmation. Through
  the API, pause a fragment instead.
* Deleting a revoked API key.
* Creating, revoking and deleting management tokens (owners only).
* Inviting and managing the organisation's members.
