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_keyon 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 anAuthorizationheader. It is refused, deliberately and always. A publishable key goes intoFoldkit.mount(), never into a server-side call.
Every code
| Code | What it means | What to do |
|---|---|---|
invalid_key | The 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_allowed | The 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_token | The 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_expired | The 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_used | A 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_suspended | The 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_scope | The 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_limited | Too 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_exceeded | A 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_found | No 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_error | Ours. | 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.
| Status | Meaning |
|---|---|
| 400 | The request body or query failed validation. |
| 401 | No credential, or one we do not accept — unknown, revoked, expired, or belonging to a suspended account. |
| 403 | Your key is fine but lacks a scope this route needs (insufficient_scope). Never used for “this is not yours”. |
| 404 | The resource does not exist, or is not yours. Deliberately the same answer for both — see below. |
| 429 | Rate limited. Honour Retry-After. |
| 402 | A plan allowance is exhausted (quota_exceeded). Waiting does not help. |
| 500 | Ours. 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-Aftertells 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.