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 the EmbedConfig passed to mount() (theme, features, locale, …) for pages that can't run their own script. It's parsed with JSON.parse as-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 &quot;-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. An origin inside data-config is 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:* or http://127.0.0.1:* (any port, for local development)
  • 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/minutePOST /embed/sessions calls (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 — a pk_... key. Required unless token or getToken is given.
  • token?: string — a pre-minted embed token (from POST /embed/sessions or POST /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 to https://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 — an editor-core document to load at mount (alternative to loadImage/loadDocument after mount).
  • image?: string | Blob — an image to load at mount (alternative to document). A string is a URL and is restricted exactly like loadImage's: an absolute https:, blob: or data: URL that is not on the editor frame's own origin. Anything else — a relative path (/photo.png), an http:/file:/app scheme, or an https://snapnedit.com/... URL — fails with an invalid_input error event after ready, leaving an empty-but-working editor. Pass a Blob when the bytes live on your own origin.
  • canvas?: { width: number; height: number } — starting canvas size for a blank document (ignored if document/image is given).

EmbedTheme

  • mode?: 'dark' | 'light' — base palette. Defaults to 'dark'.
  • colors?: Partial<Record<ThemeColorKey, string>> — per-token overrides. ThemeColorKey is one of bg, bg2, surface, surface2, line, line2, text, dim, accent, accentText, checkerA, checkerB.
  • accent?: string — shorthand for colors.accent; also used to derive accentText when you don't set one explicitly. Any valid CSS <color> (hex, rgb(), hsl(), oklch(), a keyword).
  • font?: string — a CSS font-family value. Only self-hosted @font-face declarations (see css below) 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-face block). The frame's internal markup/class names are not a stable API — a css block that targets them can break on a future release; prefer the typed colors/accent/font/radius fields wherever they cover your case.

EmbedFeatures

  • tools?: RailKey[] — which left-rail tool tabs are shown, from templates, text, shapes, elements, data, uploads, stock, draw, brand, magic, saved. Omit for every rail tool except saved (saved designs are a snapnedit-account feature and have no meaning in an embed); with tools omitted, stock follows features.stock. Listing tools explicitly is an exact allowlist — saved and stock appear 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, from layers, 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 to true; set it to false to hide it.
  • export?: { formats?: ExportFormat[]; mode?: 'download' | 'callback' | 'both' }formats restricts the export menu (from png, jpg, webp, avif, svg, pdf'gif' is an ExportFormat the export event can report but not one you can allowlist, since the GIF item is gated on the document having animations); mode controls whether the frame's own in-UI Export button triggers a browser download, fires the export event 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 as format: 'gif' with an image/gif blob and the GIF's own compact dimensions. Because 'gif' cannot appear in formats, setting formats at all is read as an exact list and hides the GIF item; leave formats unset 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 the save event). <SnapneditEditor> turns this on automatically when you pass onSave.
  • close?: boolean — show a Close button (fires the close event). Same auto-on behavior with onClose.

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. src is a Blob/File, or a URL string — and a URL string must be an absolute https:, blob: or data: URL on an origin other than the editor frame's own. Three things reject with invalid_input: a relative path (it would resolve against the frame's url, not your page's, so /photo.png never means what you think it does), a scheme outside that list (http:, file:, an app scheme), and an https: 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 resulting Blob.
  • addImage(src, opts?) — adds an image as a new layer, keeping the existing document. Same src rules as loadImage.
  • loadDocument(doc) — replaces the canvas with a full editor-core Document.
  • 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 }; opts can set scale, targetWidth, quality (lossy formats) or allPages. 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 real application/pdf whose single page is doc.width × doc.height, and 'svg' a true vector document. width/height report the document's dimensions for pdf/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: true is pdf-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/height then 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 as false. This call resolves its own promise directly — it does not itself fire the export event. The export event only fires when the end-user clicks the frame's own in-UI Export button (in 'callback'/'both' export mode); calling export() programmatically is a separate path with no event side effect.
  • exportTo(target, opts?) — renders exactly what export() renders and uploads the bytes straight from the editor frame, so your server never sees the file and the browser uploads once. opts is the same EmbedExportOptions; target is 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 with mask_required for mask-guided operations (magic-eraser, remove-watermark, generative-fill) — those need a user-painted mask, so use openTool() instead to let the user paint one in the UI.
  • openTool(target) — opens a tool or rail panel, by operation id or RailKey. 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 (on returns an unsubscribe function).
  • destroy() — tears down the iframe and all subscriptions. Call this on unmount.
  • iframe — the underlying HTMLIFrameElement, 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 absolute https: URL that is not on the editor frame's own origin; a relative path, http:, blob:/data:, or an https://snapnedit.com/... URL rejects with invalid_input. Same rule, and the same reason, as loadImage'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 to content-type, cache-control, content-disposition and the x-amz-* / x-goog-* / x-ms-* prefixes; anything else rejects with invalid_input. Sign every header you pass.
  • format? — the same format union export() 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? and format? mean the same as above; method is not accepted (a saved destination is always a signed PUT, and passing one rejects with invalid_input rather than being ignored). Passing both url and destinationId, or neither, also rejects with invalid_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 bad url, method, format or 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 as err.details.status. 403 usually means the URL expired or its signature does not cover the request you made; 400 usually means the signed Content-Type and the exported format disagree.
  • unsupported — the /embed frame is running an older release than your copy of the loader and has no exportTo (or no listDestinations, or no destinationId support) 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 — status is '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. format is one of the ExportFormat values, including 'gif' for the animated-GIF item (the one ExportFormat member that is not allowlistable in features.export.formats — it appears only for a document with animated layers). Calling handle.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 no getToken was configured to replace it; it fires once, at expiry.
  • token-expiring{ expiresAt }. The session token is about to expire; mount() calls your getToken automatically 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, and mount() collapses whatever does arrive into a single token-expiring event, retrying your getToken on its own backoff of the same shape until a token that outlives the refresh arrives. A rejected getToken — or one that hasn't settled within 30s — surfaces as an error and is retried on the same schedule; any successful refresh resets the ladder.

Theming

  • theme.mode ('light' | 'dark', default 'dark') sets the base palette; theme.accent recolors the primary accent (buttons, active states, selection) and derives a readable accentText unless you set one explicitly in theme.colors.
  • theme.font sets the editor's UI font. Only a self-hosted @font-face (declared via theme.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.css is 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. Prefer colors/accent/font/radius first.
  • export.mode does not change WHICH items the export menu offers — only where the finished bytes go. The in-UI Export button and the programmatic handle.export() share one set of builders, so neither path is a lesser version of the other: pdf is a real PDF container everywhere, and handle.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_denied at POST /embed/sessions.

    A non-browser client can spoof that origin. POST /embed/sessions takes hostOrigin as a parameter, and nothing outside a browser is obliged to tell the truth about it — so a copied pk_ 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 a pk_ 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 via getToken.

  • Tokens are exchanged over postMessage between your page and the /embed iframe, 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 unauthorized errors the next time it asks the server for anything.