Foldkit docs

Errors & troubleshooting

Every code we emit, what actually causes it, and what to check first. These are the same strings the SDK gives you and the same ones your diagnostics page shows.

The shape of an error

Every failure — from the SDK, from /v1, and on the error screen inside the iframe — carries the same three fields.

try {
  await editor.export({ kind: 'print-pdf' })
} catch (error) {
  error.code       // one of the codes below — switch on this, not on the message
  error.message    // a sentence for a human. We reword these; do not match on them.
  error.requestId  // quote this to support. It finds the exact request in our logs.
}

Check these first

Three failures account for most of the ones we see, and none of them is in your code.

  • An empty iframe with nothing in your console. The browser blocked the frame. The origin serving your page is not on your allow-list, so the frame-ancestorspolicy we sent excludes it. There is no error to catch, because nothing loaded — look in the browser’s own console for the CSP violation.
  • invalid_key on an origin that works elsewhere. Same cause, one layer up: a publishable key is only accepted from an origin you verified. Staging and localhost each need their own entry.
  • A pk_ in an Authorization header. It is refused, deliberately and always. A publishable key goes into Foldkit.mount(), never into a server-side call.

Every code

CodeWhat it meansWhat to do
invalid_keyThe key was not recognised — unknown, malformed, revoked, expired, or of the wrong class for this call.Check you pasted a pk_ into the page and an sk_ into your server, and that the key is still active in the console. All five causes answer identically on purpose, so the console is where the difference is visible.
origin_not_allowedThe publishable key is valid, but the page using it is served from an origin you have not verified.Add that exact origin — scheme, host and port — under Domains. The error message names the origin we actually saw; compare it character by character with what you configured.
invalid_tokenThe embed session token did not verify.Mint a fresh one. Most often the token was truncated in transit, or a test-environment token was used against a live tenant.
token_expiredThe access token reached the end of its 15 minutes and nothing renewed it.Pass a function to `token` rather than a string, so the SDK can fetch a fresh session by itself. A plain string can only ever produce this error once per session.
token_already_usedA session token was exchanged twice. They are single-use by design.Mint one per mount rather than caching one. React 18 StrictMode mounting twice in development is the usual cause; the SDK snippet in our guides handles it.
tenant_suspendedThe account is suspended, so its keys have stopped working. The key itself is fine.Contact us. There is nothing to change in your integration — this is the one failure that is never a configuration problem.
insufficient_scopeThe key is valid but does not hold a scope this route requires.Grant the scope in the console, or use a key that has it. A key’s scopes are fixed at creation; widening means rotating.
rate_limitedToo many requests in the current window.Back off for the number of seconds in `Retry-After` and retry. Blocked requests are not charged against the window, so a retry loop does not dig the hole deeper.
quota_exceededA plan allowance for this billing period is exhausted.Waiting does not help before the period rolls over. Check `GET /v1/usage` for which resource, and talk to us about the plan.
design_not_foundNo design with that id in your tenant — which covers both “never existed” and “belongs to someone else”.Check the id. Note that an archived design still resolves, so a 404 is never “archived”.
internal_errorOurs.Retry once — a fair number are transient. If it persists, send us the requestId from the error; it finds the exact failure in our logs.

HTTP statuses on /v1

The partner API answers with the same codes in a JSON body, alongside a status. The statuses are worth reading as a set, because two of them are deliberately not what you would guess.

StatusMeaning
400The request body or query failed validation.
401No credential, or one we do not accept — unknown, revoked, expired, or belonging to a suspended account.
403Your key is fine but lacks a scope this route needs (insufficient_scope). Never used for “this is not yours”.
404The resource does not exist, or is not yours. Deliberately the same answer for both — see below.
429Rate limited. Honour Retry-After.
402A plan allowance is exhausted (quota_exceeded). Waiting does not help.
500Ours. The response carries a request id; send it to us.

Rate limits and quotas

These are different things and the difference decides whether retrying is sensible.

  • Rate limit (429, rate_limited) — you are going too fast right now. Retry-After tells you how long to wait; waiting works.
  • Quota (402, quota_exceeded) — a plan allowance for the billing period is spent. Waiting does not help before the period rolls over.

Responses carry X-RateLimit-Limit and X-RateLimit-Window. Read your consumption against allowance from GET /v1/usage, which is the same number your invoice is built from.

Still stuck

Your console has a diagnostics page that records refusals with the origin we actually saw, the key that was presented, and the same code listed above. It answers “why is my embed blocked” faster than we can. Failing that, send us a requestId.

Building against the sandbox first removes a whole class of these: its publishable key is accepted from any origin, so nothing you see there is an allow-list problem.