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 noapiKeyId); 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 notprotected(apublicviz has the anonymous lane; aprivateone is not embeddable at all);403 embed_mint_filter_invalid— afilterskey 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
- 201
- 404
- 422
Successful Response
Not found
Validation Error