# Create Embed Token

```
POST 
/v1/embed/token
```

Mint a short-TTL, platform-signed embed token.

The customer's backend calls this with its API key (`X-Api-Key`); we authorize the target viz for that account and sign an ES256 token with the platform's rotating key — the customer holds NO signing secret. Access-control claims (`account_id` / `api_key_id` / `viz_id`) are stamped from the authenticated credential, never the body, so a token can only ever grant what the caller's own key already permits.

When called with a SESSION JWT (from the console) instead of an API key, pass `apiKeyId` so the minted token still binds an allow-list + revocation handle. Returns the token + its effective (clamped) TTL. Every refusal names the check that fired, because the caller is MINTING, not presenting a token — "this embed token does not grant access" sent people to debug a token that did not exist yet:

* `403 embed_mint_requires_api_key` — the request named no key at all (session auth with no `apiKeyId`); nothing was looked up;
* `403 embed_api_key_not_usable` — a key WAS named or presented but is not usable by this account (missing / foreign / inactive / expired);
* `404 viz_not_found` — no such viz;
* `403 embed_viz_not_owned` — the viz belongs to another account;
* `404 embed_viz_not_published` — the viz is a draft;
* `403 embed_viz_not_protected` — the viz's visibility is not `protected` (a `public` viz has the anonymous lane; a `private` one is not embeddable at all);
* `403 embed_mint_filter_invalid` — a `filters` key names no column of any dataset this viz reads, or its value cannot be coerced to that column's type. Checked HERE, last, and before the signature: the same verdict used to arrive from the embedded page, three hops later, with the link already handed to a customer. The message names the offending key and the column names that do exist.

**THE LIFETIME IS BOUNDED, AND A REDUCTION IS STATED.** `ttlSeconds` is a request, not an instruction: it is clamped into \[60, 86400] seconds, and omitting it takes a default of 600.

**THAT DEFAULT IS SIZED FOR AN IFRAME, NOT FOR A LINK YOU HAND SOMEBODY.** It fits the object this endpoint exists for: a token your backend mints per page load and re-mints when the frame posts `EMBED_TOKEN_EXPIRED`, where a short lifetime costs nothing. A URL pasted into a chat is a different product built from the same token, and the likeliest thing that happens to one is being opened later than the default allows — so ask for the lifetime your own delivery needs rather than inheriting this one. The ceiling exists because this token is a BEARER credential — whoever holds the link can use it, so a long-lived one is a long-lived key handed to whoever it was forwarded to, and it cannot be revoked on its own: the levers are disabling the API key it was minted under or setting the viz back to `private`.

When the request was reduced, the response says so: `requestedExpiresIn` carries what you asked for and `ttlNote` says why it was cut. Both are ABSENT when you got what you asked for, so their presence is the signal. Reading `expiresIn` alone cannot distinguish a grant from a reduction, and a caller that does will report a lifetime it was never given.

**THE RESPONSE STATES WHAT THE TOKEN PERMITS** (`scope`, additive 2026-09-20). `scope.fullyScoped` is the field worth asserting on, and it is NOT a synonym for "the filters were accepted": it is false whenever any of the viz's datasets is read UNFILTERED. That happens by design for every dataset that carries none of the filter columns — you cannot filter a column a table does not have — so a two-dataset dashboard scoped on `tenant`, where only one dataset has a `tenant` column, is scoped on one and open on the other. `scope.unfiltered` names those datasets and `scope.meaning` says it in words. Log this next to the link you hand out; it is the answer to the first question a customer's security reviewer asks, and before this field existed the only way to learn it was to mint a token and try to read something you should not.

THAT LIST IS IN REFUSAL ORDER, AND THE LAST ENTRY IS LAST ON PURPOSE. The view mode is the only precondition a caller satisfies by CHANGING THE DASHBOARD'S EXPOSURE; every other one is decidable while the viz is still private. So `embed_viz_not_protected` means "everything else about this mint is already in order" — which makes exactly one sequence safe for sharing a private viz: **call this endpoint FIRST, un-widened, and widen only if this is the refusal you got.** Then nothing can have changed when the answer is anything else. See `_authorize_viz_for_embed`.

**Role-gated 2026-09-08.** This used to depend on bare `JWTBearer()`, so any account member — including a Viewer — could mint a live credential that exposes a `protected`/published dashboard OUTSIDE the account, to anyone holding the token. `_authorize_viz_for_embed` (below) only ever asked "does this viz exist, belong to this account, and is it embeddable" — never "may THIS caller mint a credential for it". That is the same shape the refresh mutations once had: a genuine object check with no role axis and no role gate beside it. Minting is a WRITE (it mints a bearer credential), so it gets the same `require_creator_or_admin` viz-editor-parity gate as the rest of that programme, not admin-only — the viz was already made embeddable through the publish flow (`viz_editor`, itself creator-or-admin), so this route issues a credential for a decision an editor already made, and does not need a stricter bar than that decision did. `api_keys_may_publish` (account-level, admin-only to enable) is an orthogonal control over whether an API-KEY caller may publish a viz PUBLIC in the first place and is untouched here.

## Request[​](#request "Enlace directo al request")

## Responses[​](#responses "Enlace directo al Responses")

* 201
* 404
* 422

Successful Response

Not found

Validation Error
