Pular para o conteúdo principal

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​

Responses​

Successful Response