Embed the editor
@snapnedit/embed loads the real snapnedit editor into your page as a sandboxed iframe — the same Pixi.js canvas, tools, and AI operations as snapnedit.com/editor, themed to your brand and billed to your account. There's no separate embed product to keep in sync: the frame is /embed, the same app this docs site is part of.
Install
Script tag — the fastest path, no build step:
<div id="editor" style="height: 640px"></div>
<script src="https://snapnedit.com/embed/v1.js" data-key="pk_live_..." data-target="#editor"></script>
-
data-key— your publishable key (see Keys and billing). -
data-target— a CSS selector for the container element to mount into. -
data-config— optional, a plain JSON string merged into theEmbedConfigpassed tomount()(theme, features, locale, …) for pages that can't run their own script. It's parsed withJSON.parseas-is — no URL-decoding — so just make sure the JSON's own double quotes don't collide with the attribute's quoting (wrap the attribute in single quotes, or"-escape the inner quotes):<script src="https://snapnedit.com/embed/v1.js" data-key="pk_live_..." data-target="#editor" data-config='{"theme":{"mode":"light","accent":"#7c5cff"}}'></script>Malformed JSON here is ignored silently (the rest of the config —
data-key/data-token/data-target— still applies), so validate it by hand if the editor doesn't come up themed as expected. Anorigininsidedata-configis ignored: the script-tag loader always uses the origin it was itself served from, so a self-hosted deployment needs no configuration.
Each loader tag auto-mounts exactly once (it is stamped data-snapnedit-mounted when claimed), so two tags on one page get one editor each, in document order. Because a script-tag embed has no handle, the loader mirrors two lifecycle events onto the container as bubbling CustomEvents: snapnedit:ready (detail: { version, editor }, where editor is the full EditorHandle) and snapnedit:error (detail: { code, message }, also logged with console.error).
npm — for a JS-driven integration (npm install @snapnedit/embed):
import { mount } from '@snapnedit/embed';
const handle = await mount('#editor', {
publishableKey: 'pk_live_...',
theme: { mode: 'dark', accent: '#7c5cff' },
});
React (same package, @snapnedit/embed/react entry point):
import { SnapneditEditor } from '@snapnedit/embed/react';
function MyPage() {
return (
<SnapneditEditor
config={{ publishableKey: 'pk_live_...' }}
onExport={(e) => console.log('exported', e.format, e.blob.size)}
onSave={(e) => saveToMyBackend(e.document)}
/>
);
}
<SnapneditEditor> is a thin wrapper over mount(): it (re)mounts when publishableKey/token/origin change, forwards config.theme/config.features changes to setTheme/setFeatures without remounting, and turns on the frame's Save/Close buttons automatically when you pass onSave/onClose (unless config.features.save/.close says otherwise).
Keys and billing
Two key kinds, created from your dashboard:
- Publishable (
pk_...) — safe to ship in client-side HTML/JS. It is bound to an allowed-origins list you set in the dashboard, so it only works when loaded from a page whose origin you've allowlisted. An entry can be:- an exact origin:
https://app.example.com - a wildcard subdomain:
https://*.example.com http://localhost:*orhttp://127.0.0.1:*(any port, for local development)
- an exact origin:
- Secret (
sk_...) — never ships to a browser. Used server-side to mint scoped, per-end-user tokens (see Host-minted tokens).
Credits: every AI operation (background removal, upscale, generative fill, …) debits credits from your account, exactly like the API — in-canvas editing (crop, text, filters, layers, export) is free, and so is the one server-side operation that runs no model, resize-image, which costs 0 credits wherever it is called from. A failed job refunds the credit. See pricing for what each operation costs and what your plan includes.
A job that is rejected before it runs — your account is out of credits, say — costs nothing and consumes no cap. A job that FAILS terminally is refunded to your balance and its reservation is released from the per-token maxCredits and the key's daily cap as well, so a provider outage doesn't quietly eat an end-user's allowance — on a deployment with REDIS_URL set, where the api and worker share one counter; without Redis each process holds its own in-process counter and only the api's own releases are visible. Caps still count work that actually ran: a succeeded job holds its slot for the life of the window (24 hours for the daily cap; a per-token cap lasts the life of the token).
Rate limits, per publishable key — the same already-documented, RATE_LIMIT-style caps the rest of the API uses, with nothing extra to configure:
- 60 sessions/minute —
POST /embed/sessionscalls (one per page load that mounts the editor). - 30 jobs/minute — AI-operation job submissions across every embed session using that key. Rejected attempts count against this one on purpose.
A host-minted token (see below) can additionally carry its own per-token credit cap and operation allowlist, and every key has an optional daily credit cap shared across all its embed sessions.
Revoking a key in the dashboard blocks new sessions immediately, and blocks jobs from already-issued tokens on their next request — a token is a self-contained JWT, so a frame that is already open keeps working until it next talks to the server, at which point it starts seeing unauthorized errors. Jobs already accepted still run to completion.
Revocation and the token's allowedOperations allowlist are checked on every embed job, independently of the deployment's metering configuration and including the zero-credit paths — a result served from cache, or a request that attaches to an identical job already in flight, is refused just like a fresh one. There is no configuration under which a revoked key or a disallowed operation gets through.
Host-minted tokens
For per-end-user attribution, credit caps, or operation allowlists, mint a token server-side with your secret key instead of handing the browser a bare publishable key:
const res = await fetch('https://snapnedit.com/api/embed/tokens', {
method: 'POST',
headers: { authorization: `Bearer ${process.env.SNAPNEDIT_SECRET_KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({
endUserId: user.id,
maxCredits: 20,
allowedOperations: ['remove-background', 'upscale'],
ttlSeconds: 3600,
}),
});
const { token } = await res.json();
Then have the browser fetch a fresh token from your own backend (never mint the token in the browser — that would expose your secret key) and hand mount() a getToken:
mount(el, {
getToken: () => fetch('/api/snapnedit-token').then((r) => r.json()).then((b) => b.token),
});
getToken is called once to bootstrap the session and again whenever the frame reports the token is about to expire (token-expiring) — mount() (and <SnapneditEditor>) handle the refresh handshake for you; you only need to return a fresh token. Because it's a plain function, getToken is stripped before any config crosses the iframe boundary — it never leaves your host page — as is any function nested anywhere in the config; Blob/File/Date/typed-array values cross intact.
ttlSeconds defaults to 1 hour, and can be set anywhere from 60 seconds up to 24 hours. endUserId/maxCredits/allowedOperations are all optional — omit them for a token that behaves like the bare publishable key, scoped only by the key's own origin allowlist and daily cap.
The token's origin claim is enforced when the token is MINTED, not when it is used. POST /embed/sessions checks the requesting origin against the publishable key's allowlist and stamps the accepted origin into the token; from then on the claim is informational. It is not re-checked at /jobs, because the editor frame calls the api same-origin (https://snapnedit.com/api/... from https://snapnedit.com/embed) — there is no cross-origin request whose Origin header the server could compare it against. The allowlist is a mint-time gate; what bounds a leaked token afterwards is its short TTL, its maxCredits/allowedOperations scope, the key's daily cap, and revoking the key.
Config reference
EmbedConfig
publishableKey?: string— apk_...key. Required unlesstokenorgetTokenis given.token?: string— a pre-minted embed token (fromPOST /embed/sessionsorPOST /embed/tokens), for when you've already exchanged a key server-side.getToken?: () => Promise<string>— loader-side only; see Host-minted tokens. Never sent to the frame.origin?: string— the embed frame's origin, defaults tohttps://snapnedit.com. Override only for self-hosted deployments.locale?: string— the editor's UI language. English (en) unless overridden.theme?: EmbedTheme— see Theming.features?: EmbedFeatures— see below.document?: Document— aneditor-coredocument to load at mount (alternative toloadImage/loadDocumentafter mount).image?: string | Blob— an image to load at mount (alternative todocument). A string is a URL and is restricted exactly likeloadImage's: an absolutehttps:,blob:ordata:URL that is not on the editor frame's own origin. Anything else — a relative path (/photo.png), anhttp:/file:/app scheme, or anhttps://snapnedit.com/...URL — fails with aninvalid_inputerrorevent afterready, leaving an empty-but-working editor. Pass aBlobwhen the bytes live on your own origin.canvas?: { width: number; height: number }— starting canvas size for a blank document (ignored ifdocument/imageis given).
EmbedTheme
mode?: 'dark' | 'light'— base palette. Defaults to'dark'.colors?: Partial<Record<ThemeColorKey, string>>— per-token overrides.ThemeColorKeyis one ofbg,bg2,surface,surface2,line,line2,text,dim,accent,accentText,checkerA,checkerB.accent?: string— shorthand forcolors.accent; also used to deriveaccentTextwhen you don't set one explicitly. Any valid CSS<color>(hex,rgb(),hsl(),oklch(), a keyword).font?: string— a CSSfont-familyvalue. Only self-hosted@font-facedeclarations (seecssbelow) or system font stacks work — external font URLs (e.g. Google Fonts<link>s) are not supported inside the sandboxed frame.radius?: number— corner radius (px) for panels/buttons.css?: string— raw CSS injected into the frame for deeper overrides (e.g. a@font-faceblock). The frame's internal markup/class names are not a stable API — acssblock that targets them can break on a future release; prefer the typedcolors/accent/font/radiusfields wherever they cover your case.
EmbedFeatures
tools?: RailKey[]— which left-rail tool tabs are shown, fromtemplates,text,shapes,elements,data,uploads,stock,draw,brand,magic,saved. Omit for every rail tool exceptsaved(saved designs are a snapnedit-account feature and have no meaning in an embed); withtoolsomitted,stockfollowsfeatures.stock. Listingtoolsexplicitly is an exact allowlist —savedandstockappear if and only if you name them.aiOperations?: OperationId[]— restrict which AI tools appear in the Magic tab. Omit for every operation this deployment has enabled.panels?: PanelKey[]— which right-side panels are available, fromlayers,adjustments,filters,effects,animation,ai. Omit for all.collab?: boolean— enable live multi-cursor collaboration on the document.stock?: boolean— enable the stock-photo search tab.branding?: boolean— show the small "made with snapnedit" mark. Defaults totrue; set it tofalseto hide it.export?: { formats?: ExportFormat[]; mode?: 'download' | 'callback' | 'both' }—formatsrestricts the export menu (frompng,jpg,webp,avif,svg,pdf—'gif'is anExportFormattheexportevent can report but not one you can allowlist, since the GIF item is gated on the document having animations);modecontrols whether the frame's own in-UI Export button triggers a browser download, fires theexportevent with the blob ('callback', for you to handle), or both. Defaults to'both'inside an embed (a bare website session downloads only). Every menu item is delivered in'callback'mode — including the multi-page PDF · all pages item (real PDF bytes,format: 'pdf') and the animated GIF item, which arrives asformat: 'gif'with animage/gifblob and the GIF's own compact dimensions. Because'gif'cannot appear informats, settingformatsat all is read as an exact list and hides the GIF item; leaveformatsunset to keep it. In'both'mode the document is rendered ONCE: the file you download and the blob your callback receives are the same bytes.save?: boolean— show the Save button (fires thesaveevent).<SnapneditEditor>turns this on automatically when you passonSave.close?: boolean— show a Close button (fires thecloseevent). Same auto-on behavior withonClose.
Editor API
Every method on the handle mount()/onReady resolves with is async and round-trips to the frame:
loadImage(src, opts?)— replaces the canvas contents with an image.srcis aBlob/File, or a URL string — and a URL string must be an absolutehttps:,blob:ordata:URL on an origin other than the editor frame's own. Three things reject withinvalid_input: a relative path (it would resolve against the frame's url, not your page's, so/photo.pngnever means what you think it does), a scheme outside that list (http:,file:, an app scheme), and anhttps:URL on the frame's own origin (https://snapnedit.com/...). The frame fetches URLs itself, with its own origin and credentials, and hands you back the exported bytes — so it will not be pointed at arbitrary schemes, nor at its own origin, on a host's behalf. When the bytes live on your own origin, fetch them in your page and pass the resultingBlob.addImage(src, opts?)— adds an image as a new layer, keeping the existing document. Samesrcrules asloadImage.loadDocument(doc)— replaces the canvas with a fulleditor-coreDocument.getDocument()— returns the current document (the active page).getPages()— returns every page of a multi-page document.newDocument(width, height)— starts a blank canvas at the given size.export(format, opts?)— renders and returns{ blob, width, height };optscan setscale,targetWidth,quality(lossy formats) orallPages. Every format goes through the same builder the frame's own Export menu uses, so the bytes are exactly what a download would have saved:'pdf'is a realapplication/pdfwhose single page isdoc.width × doc.height, and'svg'a true vector document.width/heightreport the document's dimensions forpdf/svg(the size options raise the DPI of the raster embedded in a PDF page, never the page itself) and the rendered bitmap's for the raster formats.allPages: trueispdf-only and produces one PDF containing every page of the project, one PDF page per project page sized to its own document — the same output as the menu's PDF · all pages item;width/heightthen report the active page, since pages may differ in size. It is ignored (not rejected) for every other format, and omitting it is the same asfalse. This call resolves its own promise directly — it does not itself fire theexportevent. Theexportevent only fires when the end-user clicks the frame's own in-UI Export button (in'callback'/'both'export mode); callingexport()programmatically is a separate path with no event side effect.exportTo(target, opts?)— renders exactly whatexport()renders and uploads the bytes straight from the editor frame, so your server never sees the file and the browser uploads once.optsis the sameEmbedExportOptions;targetis either{ url, method?, headers?, format? }(a presigned URL you minted) or{ destinationId, headers?, format? }(a storage destination saved on your snapnedit account — nothing to mint). See Save straight to your storage and Save to a saved destination.listDestinations()— the saved storage destinations on the embed session's account, as{ id, name, provider, bucket, isDefault }[], so you can offer a picker. Resolves with[]when the account has none.run(operation, params?)— runs an AI operation programmatically. Rejects withmask_requiredfor mask-guided operations (magic-eraser,remove-watermark,generative-fill) — those need a user-painted mask, so useopenTool()instead to let the user paint one in the UI.openTool(target)— opens a tool or rail panel, by operation id orRailKey. The way to trigger mask-based tools, and generally the right call for anything you want the user to interact with rather than run headlessly.undo()/redo()— standard history navigation.select(ids)— sets the layer selection.getState()— returns{ canUndo, canRedo, selection, page, pageCount, width, height, dirty, busy }.setTheme(theme)— applies theme changes live, no remount.setFeatures(features)— applies feature changes live, no remount.setLocale(locale)— switches the editor's UI language.on(event, cb)/off(event, cb)— subscribe/unsubscribe to events (onreturns an unsubscribe function).destroy()— tears down the iframe and all subscriptions. Call this on unmount.iframe— the underlyingHTMLIFrameElement, read-only, for cases you need direct DOM access (sizing, focus).
Calls to export, exportTo, run, loadImage, and addImage are given a longer timeout (180s) than other calls (30s), since they can involve real inference work (and, for exportTo, a full upload).
Save straight to your storage
Most hosts do the same thing with an export: hand the bytes to their own page, then re-upload
them to their own bucket. handle.exportTo() skips the middle step — the editor frame
renders and PUTs the file directly to a presigned URL you mint, so the bytes never touch
your page or your server, and the browser uploads once.
const result = await handle.exportTo(
{ url: presignedUrl, format: 'png', headers: { 'x-amz-acl': 'private' } },
{ scale: 2 },
);
// result: { ok: true, status: 200, bytes: 481203, mime: 'image/png', width: 1280, height: 960, etag: '"9f8…"' }
The second argument is the same EmbedExportOptions export() takes
(scale, targetWidth, quality, allPages), and it runs the same builders — a
format: 'pdf' upload is a real PDF, byte-for-byte what a download would have saved.
1. Mint a presigned URL on your server
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
const s3 = new S3Client({ region: 'us-east-1' });
const url = await getSignedUrl(
s3,
new PutObjectCommand({ Bucket: 'my-bucket', Key: `users/${user.id}/design.png`, ContentType: 'image/png' }),
{ expiresIn: 300 },
);
Return that URL to the browser from your own endpoint and pass it to exportTo as
target.url. The URL is a capability — mint it per save, scoped to one key, with a short
expiresIn.
The ContentType you sign must match the format you ask for. S3 signs the
Content-Type header into the signature, so a URL signed for image/png and an
exportTo({ format: 'pdf' }) fail with a 403. The formats map to image/png,
image/jpeg (jpg), image/webp, image/avif, image/svg+xml and application/pdf.
exportTo sends the exported mime as Content-Type automatically; pass your own
headers['content-type'] if you signed a different string.
2. Allow the editor frame's origin in your bucket's CORS
The upload request comes from https://snapnedit.com — the editor iframe — and not
from your own site's origin. Allowlisting your site instead is the single most common reason
a first attempt fails. ETag has to be in the exposed headers for the result to carry it;
without that, result.etag is null.
S3 — bucket → Permissions → Cross-origin resource sharing:
[
{
"AllowedOrigins": ["https://snapnedit.com"],
"AllowedMethods": ["PUT"],
"AllowedHeaders": ["content-type", "cache-control", "content-disposition", "x-amz-*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
Cloudflare R2 — bucket → Settings → CORS policy (same document, different spelling):
[
{
"allowed": {
"origins": ["https://snapnedit.com"],
"methods": ["PUT"],
"headers": ["content-type", "cache-control", "content-disposition", "x-amz-*"]
},
"exposeHeaders": ["ETag"],
"maxAgeSeconds": 3000
}
]
Add "POST" to the methods if you sign POST uploads, list every custom header you pass in
AllowedHeaders, and substitute your own deployment's origin if you self-host and set
config.origin.
EmbedExportTarget
url: string— the presigned URL. Must be an absolutehttps:URL that is not on the editor frame's own origin; a relative path,http:,blob:/data:, or anhttps://snapnedit.com/...URL rejects withinvalid_input. Same rule, and the same reason, asloadImage's: the frame runs with snapnedit's origin and credentials, and must never be aimed at itself on a host's behalf.method?: 'PUT' | 'POST'— defaults to'PUT', what presigned upload URLs expect.headers?: Record<string, string>— extra request headers, at most 16. Allowlisted case-insensitively tocontent-type,cache-control,content-dispositionand thex-amz-*/x-goog-*/x-ms-*prefixes; anything else rejects withinvalid_input. Sign every header you pass.format?— the same format unionexport()takes (png,jpg,webp,avif,svg,pdf), defaulting to'png'.
Or, instead of url, name a saved destination:
destinationId: string— the id of a storage destination on the embed session's account.headers?andformat?mean the same as above;methodis not accepted (a saved destination is always a signedPUT, and passing one rejects withinvalid_inputrather than being ignored). Passing bothurlanddestinationId, or neither, also rejects withinvalid_input.
The request is always credentials: 'omit' (no cookies ever ride along to your endpoint),
mode: 'cors' and redirect: 'error' — a target that redirects fails rather than quietly
re-sending the artwork somewhere you did not name. The response body is never read: you
get the status, the byte count and the ETag, and nothing else the endpoint said.
Save to a saved destination
If the bucket already lives on your snapnedit account as a storage destination, there is nothing to mint and nothing to configure on your page:
const destinations = await handle.listDestinations();
// → [{ id: 'dst_1', name: 'Production', provider: 'aws-s3', bucket: 'my-app-images', isDefault: true }]
const result = await handle.exportTo({ destinationId: 'dst_1', format: 'png' });
// → { ok: true, status: 200, bytes: 91234, mime: 'image/png', width: 1024, height: 768,
// etag: '"…"', key: 'snapnedit/2026/09/13/….png', bucket: 'my-app-images' }
The frame asks the api for a one-shot signed slot with its own embed session (the same
credential every other call uses), then PUTs into it exactly as it would into a URL you
minted. Your backend signs nothing, and your page never sees the bucket's credentials — or
its region or endpoint: listDestinations() returns only what a picker needs.
Two extra fields come back that a self-minted URL does not get: key and bucket,
the object the bytes landed at. You chose neither, so they are reported.
No CORS configuration is needed on your host page — the upload is a cross-origin request
from the editor frame, not from your document. Your bucket still needs the CORS rule
allowing PUT from https://snapnedit.com, exactly as the presigned-URL path does; see
the rule.
Beyond the codes below, this path can also reject with the api's own error codes —
not_found for an id that isn't on the session's account, unauthorized for an expired
embed session.
Failures
exportTo rejects — it never resolves with a failure — with these EmbedError codes:
invalid_input— a badurl,method,formator header. Nothing was rendered and nothing was sent.network_error— the request never produced a response: a CORS refusal, a DNS/TLS failure, or a redirect. The browser deliberately hides the reason from the page, so check the editor frame's devtools console for the actual CORS message.upload_failed— the endpoint answered with a non-2xx status, which is on the error aserr.details.status.403usually means the URL expired or its signature does not cover the request you made;400usually means the signedContent-Typeand the exported format disagree.unsupported— the/embedframe is running an older release than your copy of the loader and has noexportTo(or nolistDestinations, or nodestinationIdsupport) at all. Newer frames against older loaders are unaffected: the protocol only ever gains methods.
Events
Subscribe with handle.on(name, callback):
ready—{ version }. The frame finished booting;mount()'s promise resolves at the same moment.change—{ dirty, pageCount }. The document changed (any edit).selection—{ ids, kind }. The layer selection changed.job—{ operation, status, jobId?, credits, durationMs?, error?, endUserId? }. An AI operation's job lifecycle —statusis'started','succeeded', or'failed'.export—{ format, blob, width, height }. Fires only when the end-user clicks the frame's own in-UI Export button, in'callback'/'both'export mode.formatis one of theExportFormatvalues, including'gif'for the animated-GIF item (the oneExportFormatmember that is not allowlistable infeatures.export.formats— it appears only for a document with animated layers). Callinghandle.export()programmatically does not fire this event — it resolves its own promise with the same shape instead (see Editor API).save—{ document, pages }. The user (or you, programmatically) triggered Save.close—{}. The user clicked the Close button.error—{ code, message }. Something failed outside a specific call (e.g. a rejected token refresh).code: 'token_expired'means the session token ran out and nogetTokenwas configured to replace it; it fires once, at expiry.token-expiring—{ expiresAt }. The session token is about to expire;mount()calls yourgetTokenautomatically if you provided one — you don't need to handle this event yourself unless you want to react to it (e.g. show a "reconnecting" indicator). If the token is already expired, both sides back off rather than spin: the frame spaces out its own reports exponentially (1s, 2s, 4s … capped at 30s) instead of re-reporting every second, andmount()collapses whatever does arrive into a singletoken-expiringevent, retrying yourgetTokenon its own backoff of the same shape until a token that outlives the refresh arrives. A rejectedgetToken— or one that hasn't settled within 30s — surfaces as anerrorand is retried on the same schedule; any successful refresh resets the ladder.
Theming
theme.mode('light' | 'dark', default'dark') sets the base palette;theme.accentrecolors the primary accent (buttons, active states, selection) and derives a readableaccentTextunless you set one explicitly intheme.colors.theme.fontsets the editor's UI font. Only a self-hosted@font-face(declared viatheme.css) or a system font stack (system-ui,"Helvetica Neue", sans-serif, …) will actually load — an external URL (Google Fonts, Adobe Fonts, etc.) is not fetched from inside the sandboxed frame.theme.cssis an escape hatch for anything the typed fields don't cover. Treat it as unstable: the frame's internal DOM structure and class names are not a versioned public API and can change between releases without notice. Prefercolors/accent/font/radiusfirst.export.modedoes not change WHICH items the export menu offers — only where the finished bytes go. The in-UI Export button and the programmatichandle.export()share one set of builders, so neither path is a lesser version of the other:pdfis a real PDF container everywhere, andhandle.export('pdf', { allPages: true })is the programmatic twin of the menu's PDF · all pages item.
Security notes
-
A publishable key is not a secret, and it is not secret-equivalent either — its threat model is the same as a Stripe or Google Maps publishable key. It is meant to be visible in your page source. A real browser reports the loading page's origin honestly, so the origin allowlist you set in the dashboard is what stops a copied key from working on someone else's site: a mount from an origin you haven't allowlisted is refused with
origin_deniedatPOST /embed/sessions.A non-browser client can spoof that origin.
POST /embed/sessionstakeshostOriginas a parameter, and nothing outside a browser is obliged to tell the truth about it — so a copiedpk_plus a forged origin can be replayed by a script. The allowlist and the limits bound the blast radius; they do not make the key secret. What actually caps the damage is the per-key session and job rate limits, the key's optional daily credit cap, and revoking the key from the dashboard. Set a daily cap on every publishable key you ship, and treat an unexplained spike in embed usage as a reason to rotate. If you need hard per-user limits, mint host tokens from your backend instead and never ship apk_at all. -
A secret key must never reach the browser. Mint tokens from your own backend (
POST /embed/tokens) and hand the browser only the resulting short-lived token viagetToken. -
Tokens are exchanged over
postMessagebetween your page and the/embediframe, scoped to the frame's origin — never put a token in a URL (query string, fragment, or otherwise), where it could leak via referrers, browser history, or logs. -
Revoking a key in the dashboard blocks new sessions immediately and blocks jobs from already-minted tokens on their next request — the server checks the key's live state on every job, not just at mint time, whatever the deployment's metering settings and even on cached or coalesced results (the live check is served from a roughly 5-second per-account cache, so a revocation takes effect within a few seconds). An already-open frame is not force-closed: it keeps its token and starts surfacing
unauthorizederrors the next time it asks the server for anything.