Skip to main content

Async and batch

Synchronous renders are the simplest integration, but a request that waits on a 200-page report ties up your worker, and a loop over 5,000 certificates hits rate limits. For both, hand the work to Formfeed and let it tell you when it is done.

Async single render​

Add "mode": "async":

curl https://api-eu.formfeed.dev/v1/renders \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Idempotency-Key: report-2026-09" \
-H "Content-Type: application/json" \
-d '{ "template": "monthly-report", "data": { … }, "mode": "async", "meta": { "report_id": "2026-09" } }'

Everything that can refuse the request (the body, the plan, the template, validate_data, the quota) is checked before the answer. Then the answer is 202 Accepted with the render in status queued, a Location header that points at the render's result, and Retry-After: 5:

{ "id": "rnd_0f3c9a2e7b1d4e5f8a6c3b2d1e0f9a8b", "status": "queued", "download_url": null, "meta": { "report_id": "2026-09" }, … }

Queued renders of one workspace run as many at a time as the plan's concurrent renders allow; the others wait their turn. Then either:

  • receive a webhook (recommended): a render.completed or render.failed event arrives at your webhook endpoints, carrying the full render with its download_url. A "webhook_url": "https://…" in the request also sends this render's render.* events to that URL; they are signed when the request also sends a webhook_secret (16–200 characters), and unsigned without one (see per-render webhook URLs);
  • or poll GET /renders/{id} every second or two until status is succeeded or failed, or ask the URL in Location, which answers 202 until the render has finished. Polling does not count against the rate limit.

The SDKs wrap polling in one call (by default every second, for at most two minutes):

const queued = await client.renders.create({
template: 'monthly-report',
data,
mode: 'async',
});
const done = await client.renders.waitFor(queued.id, { timeoutMs: 120_000 });
queued = client.renders.create(template="monthly-report", data=data, mode="async")
done = client.renders.wait_for(queued.id, timeout=120)

The automatic fallback​

A sync request that has found no free render slot within 30 seconds, or has no answer after 60 seconds, also answers 202, with X-Render-Async-Fallback: true. The render is not cancelled: it finishes in the background and reports through webhooks or polling like any async render, and it is charged once. Code that makes sync requests should treat 202 as "poll Location", not as an error.

Location is the render's result, /v1/renders/{id}/result as an absolute URL, and every 202 carries Retry-After: 5. The result answers 202 again while the render is queued or rendering. Once the render has finished, it answers what the sync request would have: the render, the file when the request asked for ?output=binary (which Location then repeats), or the render's error as a problem with its render_id. Clients that follow a 202 by themselves, such as Power Automate and Logic Apps, wait there for the finished document.

Batch​

POST /v1/renders/batch queues up to 1,000 renders as one job. It needs the render:create scope.

curl https://api-eu.formfeed.dev/v1/renders/batch \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Idempotency-Key: course-cs101-certificates" \
-H "Content-Type: application/json" \
-d '{
"template": "certificate",
"zip": true,
"items": [
{ "data": { "name": "Jane Doe" }, "filename": "jane-doe.pdf", "meta": { "student_id": "s-1" } },
{ "data": { "name": "John Doe" }, "filename": "john-doe.pdf", "meta": { "student_id": "s-2" } }
],
"meta": { "course": "cs101" }
}'
FieldTypeDefaultMeaning
itemsarray, 1 to 1,000–One render request per document.
templatestring–The template of every item that names no source (template, html or url) of its own.
outputstring–The output of every item that names none; without it each item gets its template's format.
canary_keystring, 1–200 characters–The canary_key of every item that names none (canaries).
zipbooleanfalseAlso pack every successful output into one archive.
webhook_urlstring–A public https:// address that receives this batch's events (webhooks).
webhook_secretstring, 16–200 characters–Signs the deliveries to webhook_url; needs webhook_url.
metaobject{}Your own fields for the job, echoed in it and its job.* events. Each item has its own meta.

An item takes the fields of a single render, with these differences: mode is always async, an item's own webhook_url and dedupe have no effect, and items are never deduplicated. The batch renders in the region of its first item; leave region out.

Everything is checked before anything is queued: an invalid item answers 400 invalid_request with its position in index, an item that fails validate_data answers 422 data_validation_error with its index, and on a plan without overage (Free) a live batch needs at least as many units left as it has items, else 402 quota_exceeded. Either way nothing of the batch is queued.

A batch is always asynchronous. The answer is 202 with the job, Retry-After: 5 and a Location header with the job as an absolute URL, /v1/jobs/{id}:

{ "id": "job_5b2e8c1d9f3a4b6e8c0d2f4a6b8c0e1d", "type": "batch", "status": "queued", "total": 2, "succeeded": 0, "failed": 0, "zip_url": null, "zip_expires_at": null, "items": [ … ], "meta": { "course": "cs101" }, … }

Follow it with GET /v1/jobs/{id} (scope render:read) or wait for its webhook:

Job statusMeaning
queued, processingItems are waiting or rendering
completedEvery item succeeded
partially_failedSome items failed; the others have outputs
failedEvery item failed

The job lists its renders under items, in the order you sent them, each a full render with its own status, download_url, error and meta. total, succeeded and failed count them; completed_at is set when the last item has finished.

With "zip": true, zip_url points at one archive of all successful outputs once the job is done, and zip_expires_at says when that link expires. Each file in the archive is named after its item's filename, prefixed with the item's position in the request so names stay unique and say which item a file is: 0001-jane-doe.pdf for the first item, 0002-john-doe.pdf for the second. A failed item leaves its number out. The link is signed and valid for up to seven days; GET /jobs/{id} issues a fresh one until the archive itself is deleted, which happens when the first file in it expires. From then on zip_url is null.

A failing item never stops the others. Check failed and the items' error to retry exactly the ones that failed.

Webhooks of a batch​

Every item sends its own render.completed or render.failed event when it finishes. When the last item has finished (and, with zip, once the archive is ready), the job sends job.completed (status completed or partially_failed) or job.failed (every item failed), carrying the whole job with every item as a full render. Your webhook endpoints receive the event types they subscribed to. The batch's own webhook_url receives all of them: one render.* event per item and the job.* event.

Canaries in a batch​

When the template's channel has a canary, a batch makes one choice for all its items, so the documents of one batch share a version: by its Idempotency-Key, which keeps a retry of the batch on the same side, or at random without one. An item with a canary_key (its own or the batch's) is decided by that key instead.

Units​

Every item is metered like a single render, and a failed item costs nothing. The zip costs nothing. Items with an e-invoice take one each from the day's test e-invoices when the batch is a test.

With the SDKs (jobs.waitFor polls every two seconds for at most ten minutes by default):

const job = await client.renders.batch({
template: 'certificate',
items: students.map((s) => ({
data: { name: s.name },
filename: `${s.slug}.pdf`,
meta: { student_id: s.id },
})),
zip: true,
});
const finished = await client.jobs.waitFor(job.id);
console.log(finished.succeeded, finished.failed, finished.zip_url);
job = client.renders.batch([{"data": {"name": s.name}, "meta": {"student_id": s.id}} for s in students], template="certificate", zip=True)
finished = client.jobs.wait_for(job.id)

More than 1,000 documents​

Split the list into chunks of 1,000 and submit one batch per chunk, each with its own idempotency key (course-cs101-certificates-part-3). Every batch counts once against the rate limit, however many items it has.

From a spreadsheet, without code​

The app's Renders → Batch from spreadsheet dialog turns each row of a CSV or Excel file into one item of a batch. See Batch from a spreadsheet.