Errors
Every error the API answers is an RFC 9457 problem with Content-Type: application/problem+json; charset=utf-8. This one is a synchronous render whose template does not compile:
{
"type": "https://docs.formfeed.dev/errors/template-syntax",
"title": "Template syntax error",
"status": 422,
"detail": "unknown block tag: endfo",
"code": "template_syntax_error",
"location": { "line": 41, "column": 3 },
"render_id": "rnd_7da09ab6cd9946b798c187c2a9350852",
"request_id": "req_4b7c1e9a2f3d4c5b8a6e7f9012"
}
Fields
Every problem has these fields:
| Field | Meaning |
|---|---|
code | Stable, machine-readable error code. Branch on this, not on title or detail. |
status | The HTTP status, repeated in the body. It always follows from the code, as the table below lists. |
title | Short description of the code; the same for every problem with this code. |
detail | What went wrong in this request, for humans and logs. The wording can change, so never parse it. |
type | A link to the page for this code. |
request_id | The id of this request, req_ followed by 26 hex digits. The same value is in the X-Request-Id response header. Send your own X-Request-Id header (up to 255 letters, digits, _, - and =) and Formfeed uses it instead. Include it when you contact support. |
Many problems add fields of their own:
| Field | Where |
|---|---|
issues | invalid_request when the body or the query does not fit the contract: one entry per problem, with path (the field, such as mode or post.watermark.text; empty for the body as a whole) and message. |
errors | data_validation_error and einvoice_data_invalid: every place where the data does not fit, with path, message and, for e-invoices, the rule. |
location | On the problems of a render that failed while it ran. For a template error: line and column in the part that failed (the body, the header or the footer; the problem does not say which). For a Word or PowerPoint template: part (such as word/document.xml) and, where known, paragraph. null when the failure has no place in the template. |
field | The request field the problem is about, such as output, validate_data, post.merge_after or storage.key. |
index | In a batch, the item that failed (counted from 0). In a PDF tool, the position of the source in sources. |
render_id | The rnd_… id of the render the problem is about: a synchronous render or a PDF tool that failed after it was recorded, which the render log and GET /v1/renders/{id} then show, or the source render a PDF tool could not use. |
feature, plan, limit | forbidden when the plan does not include something: which feature, the plan, and the number the plan allows. |
einvoice, accessibility | The validator's reports of an e-invoice or a PDF/UA-1 file that failed. |
Some codes add more, such as page_count on page_limit_exceeded or period_end on quota_exceeded; the page of each code lists them.
Failed async renders
An async render, a batch item, and a synchronous render that went on in the background (a 202 answer) fail later, so no problem comes back from the request. The failure is kept on the render instead:
GET /v1/renders/{id}and therender.failedwebhook carry it inerror, in a shorter form:code,message(whatdetailwould say) and the fields of the code, such aslineandcolumnorpage_count. The e-invoice and PDF/UA reports are in the render's owneinvoiceandaccessibilityfields.GET /v1/renders/{id}/resultanswers a failed render with the full problem, as the synchronous request would have, includingrender_id.
A failed render, its other fields left out:
{
"id": "rnd_7da09ab6cd9946b798c187c2a9350852",
"status": "failed",
"error": {
"code": "page_limit_exceeded",
"message": "Document has 212 pages; the plan allows 200",
"page_count": 212
}
}
A render that failed on Formfeed's side three times has "code": "worker_exhausted", the last attempt's message and attempts; see retrying.
Handling errors in code
The SDKs turn every problem into one error class. TypeScript:
import { Formfeed, FormfeedError } from '@formfeed/sdk';
const client = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY! });
try {
await client.renders.create({ template: 'invoice', data });
} catch (e) {
if (!(e instanceof FormfeedError)) throw e;
switch (e.code) {
case 'template_syntax_error':
case 'template_runtime_error':
// a template bug: alert whoever owns the template, do not retry
console.error('template error', e.message, e.problem, e.requestId);
break;
case 'quota_exceeded':
// stop sending live renders until the period resets or the plan changes
break;
default:
throw e;
}
}
Python:
import os
from formfeed import Formfeed, FormfeedError
client = Formfeed(os.environ["FORMFEED_API_KEY"])
try:
client.renders.create(template="invoice", data=data)
except FormfeedError as e:
if e.code in ("template_syntax_error", "template_runtime_error"):
# a template bug: alert whoever owns the template, do not retry
print("template error", e.message, e.problem, e.request_id)
elif e.code == "quota_exceeded":
pass # stop sending live renders until the period resets or the plan changes
else:
raise
TypeScript (FormfeedError) | Python (FormfeedError) | Meaning |
|---|---|---|
code | code | The problem's code, or a code of the SDK itself (below) |
status | status | The HTTP status; 0 when no answer arrived |
message | message | The problem's detail, else its title |
problem | problem | The whole problem body, with every field above; null / None for an SDK error |
requestId | request_id | The X-Request-Id header of the answer |
Codes the SDKs raise themselves, without an API problem: network_error (no answer after the retries, a request timeout included), timeout (only from waitFor / wait_for, which gave up waiting for a render or job), not_ready (a download of a render that has no file), download_failed, invalid_response (TypeScript: a success answer that is not JSON), and http_<status> for an error answer without a code.
renders.waitFor / wait_for does not raise for a failed render: it returns the render with status: "failed", and its error says why.
Retrying
The SDKs retry 429, 503 and network errors by themselves: up to 3 times (maxRetries / max_retries, 0 turns it off), waiting the seconds of Retry-After when the answer has the header, otherwise 0.5, 1 and 2 seconds, plus up to 0.25 seconds of jitter and never more than 30 seconds at a time. A request that gets no answer within the client's timeout (timeoutMs / timeout, 120 seconds by default) counts as a network error. Every call that creates a render (renders, batches, PDF tools) sends a generated Idempotency-Key, and its retries reuse it. They never retry 500, 502 or 504. See the SDK pages for TypeScript and Python.
Without an SDK, or for what the SDKs leave to you:
| Answer | Retry? | How |
|---|---|---|
429 rate_limited | yes | After the seconds in Retry-After, with the same Idempotency-Key. |
503 region_unavailable, office_unavailable, conformance_unavailable | yes | After the seconds in Retry-After, with the same Idempotency-Key. |
500 worker_exhausted, tool_failed | yes, a few times | With growing pauses (for example 5, 30 and 120 seconds) and the same Idempotency-Key. If it keeps failing, send the request_id to support. |
500 pdfa_conversion_failed | no | The same document fails the same way; send the render_id to support. |
504 render_timeout | only if the cause passed | A slow server for images or fonts can recover; a template or page that is too slow fails again. See the page of the code. |
| No answer at all (timeout, connection reset) | yes | With the same Idempotency-Key: if the first attempt did arrive, you get its answer instead of a second render. |
Any other 4xx, and 501 | no | Fix what the problem names, then send the request with a new Idempotency-Key or none. |
Why a new key after a 4xx: the first answer to a POST with an Idempotency-Key is stored for 24 hours per workspace and returned again, with Idempotent-Replayed: true, for every repeat of that key. That holds for problems too. Only answers of 500 and above are not stored, and neither are a 401 or a 429 of the rate limit, which are answered before the key is looked at. A corrected request sent with the old key therefore gets the old problem back.
Async renders and batch items are retried by the queue itself when they fail on Formfeed's side (a status of 500 or above, except pdfa_conversion_failed): three attempts, the second 5 seconds and the third 10 seconds after the one before. When the third fails too, the render fails with worker_exhausted and keeps the last attempt's message. Every other failure fails the render at once. To try a failed async render again yourself, send the request with a new Idempotency-Key: the old key returns the stored 202 of the failed render.
A failed render costs nothing, whatever the code.
All codes
| HTTP | Code | Meaning | Retry |
|---|---|---|---|
| 400 | invalid_request | The body or a parameter does not fit the contract, or a combination of fields is not allowed. | no |
| 401 | unauthorized | The request carries no API key, or the key is unknown, revoked or expired. | no |
| 402 | quota_exceeded | The units of the current period are used up and the plan cannot go on with overage, or the overage reached its hard cap. | no |
| 403 | forbidden | The key lacks the scope this endpoint needs, the plan lacks the feature, or the caller may not do this. | no |
| 403 | test_output_unavailable | A Free test render asked a Word or PowerPoint template for its own format; it makes the marked PDF only. | no |
| 404 | not_found | The render, job, file, webhook, partial or path does not exist in this workspace. | no |
| 404 | template_not_found | No template with this slug or id has the version the request names in the workspace of the key. | no |
| 404 | channel_not_found | The request names a release channel the template does not have. | no |
| 409 | conflict | A slug is taken, the template or shared partial changed since the version you based your change on, or a device code was used already. | no |
| 409 | schema_breaking_change | Publishing or moving a channel would put a version live whose stored data schema breaks callers of the current one. | no |
| 409 | channel_in_use | Renders of the last hour used the channel you are deleting; pass force=true. | after an hour without renders |
| 409 | listen_session_limit | Three formfeed listen sessions are already running in the workspace. | after one ends |
| 409 | last_workspace | The workspace you are deleting is the last one of the organisation. | no |
| 409 | storage_not_configured | The request needs the workspace's own bucket (storage.key), but the workspace has no storage connection. | after connecting one |
| 410 | render_input_expired | The request of the render was not kept, or its retention period ended. | no |
| 410 | listen_session_ended | The formfeed listen session a resend or delivery names is gone. | no |
| 413 | file_too_large | An uploaded file is over its limit: 10 MB for the file library, 20 MB for a document to convert or a Word or PowerPoint template. | no |
| 415 | file_type_unsupported | The file is not an office document Formfeed converts, is macro-enabled, or is not the kind the template takes. | no |
| 422 | channel_name_invalid | A release channel name is malformed or reserved. | no |
| 422 | template_syntax_error | The template could not be compiled. | no |
| 422 | template_runtime_error | The template compiled, but failed while it ran with your data. | no |
| 422 | data_validation_error | The data does not match the template's data schema (with validate_data: true). | no |
| 422 | navigation_failed | A url render could not load the page. | no |
| 422 | page_limit_exceeded | The PDF has more pages than the plan allows. | no |
| 422 | output_too_large | The rendered file, or its HTML, is larger than the limit. | no |
| 422 | source_not_found | A source of a PDF tool or of post.merge_after does not exist, or its output is gone. | no |
| 422 | source_invalid | A source is not a finished, readable PDF (or office document, for a conversion). | no |
| 422 | source_encrypted | A source is password-protected. | no |
| 422 | office_document_invalid | The office document's container is damaged or fails a safety check. | no |
| 422 | office_document_too_large | The office document unpacks to more than Formfeed processes, or a filled template grows too large. | no |
| 422 | office_conversion_failed | LibreOffice could not convert the document. | no |
| 422 | unsupported_in_office | A Word or PowerPoint template uses something office templates cannot run, such as an include. | no |
| 422 | einvoice_data_invalid | The _invoice block of an e-invoice is missing or breaks a rule, or the validator refused the finished file. | no |
| 422 | einvoice_profile_unsupported | The e-invoice options name a profile or a flavour Formfeed does not create. | no |
| 422 | einvoice_display_mismatch | With display_check: "strict", the PDF does not show a value the XML of its e-invoice states. | no |
| 422 | pdfua_validation_failed | With pdf.ua and the strict check, the file fails a rule of PDF/UA-1; accessibility names the rules. | no |
| 422 | storage_test_failed | The test object could not be written to the bucket, so the storage connection was not saved; error carries the provider's code. | after fixing the key or bucket |
| 429 | rate_limited | Too many requests that start work; wait for Retry-After. | yes |
| 500 | tool_failed | A PDF tool, or the validation of a PDF/UA file, failed inside Formfeed. | once |
| 500 | pdfa_conversion_failed | The PDF could not be made a valid PDF/A-3b, which an e-invoice has to be. | no |
| 500 | worker_exhausted | A failure on Formfeed's side that the retries did not fix, or an unexpected error. | yes |
| 501 | not_implemented | Reserved for a documented feature that is not available yet; nothing answers with it today. | no |
| 503 | region_unavailable | The region has no free render slot right now, or no render service at all. | after Retry-After |
| 503 | office_unavailable | The office converter is busy or not reachable. | yes |
| 503 | conformance_unavailable | The service that converts and validates e-invoices and validates PDF/UA files is busy or not reachable. | yes |
| 504 | render_timeout | A step of the render took longer than its limit. | only if the cause passed |
New codes can be added within /v1. Treat an unknown code by its HTTP status.