API overview
The Formfeed API is a JSON-over-HTTPS REST API. This page covers the conventions that apply to every endpoint; the API reference has every field.
Base URL
https://api-eu.formfeed.dev/v1
Use this host. Formfeed renders in Frankfurt and stores files in the EU, and the SDKs use this host by default. https://api.formfeed.dev/v1 and https://api-us.formfeed.dev/v1 reach the same gateway today: the host does not decide where a document is rendered, the workspace of your key does, and the EU is the only region that renders. See Data residency.
Authentication
Send an API key as a bearer token:
Authorization: Bearer ff_live_3f9c...
X-API-Key: ff_live_… works too, for clients that cannot set Authorization. Keys belong to one workspace and carry scopes; ff_test_ keys render for free. See Authentication.
Requests and responses
- Request bodies are JSON with
Content-Type: application/json. Field names aresnake_case. Uploads (library files, Word and PowerPoint templates, documents to convert) aremultipart/form-data. A body that is not valid JSON, misses a required field or has a value of the wrong type answers400 invalid_request, in most cases with anissueslist that names each problem'spathand amessage. - Responses are JSON. Errors are
application/problem+json(RFC 9457) with a stablecode; see Errors. - Renders, batch jobs, templates and library files have ids with a type prefix followed by 32 hexadecimal characters:
rnd_…(render),job_…(batch job),tpl_…(template),fil_…(library file). Webhook events haveevt_…ids. Webhook endpoints, workspaces and listen sessions have plain UUIDs. Wherever a render, job, template or file id is expected you may also send the plain UUID, and a template also by its slug (invoice-de). - Timestamps are ISO 8601 in UTC. Depending on the field they are written with
Zor+00:00and with or without fractions of a second (2026-09-11T10:00:00Z,2026-09-11T10:00:00.4821+00:00), so parse them with a date library instead of comparing strings. - Every response carries an
X-Request-Id, and every problem the same value asrequest_id. Include it when you contact support.
Headers
Request headers the API reads:
| Header | When |
|---|---|
Authorization: Bearer <key> | Every request except GET /v1/openapi.json and the device login. See Authentication. |
X-API-Key: <key> | Instead of Authorization. When both are sent, Authorization counts. |
Content-Type | application/json, or multipart/form-data for uploads. |
Idempotency-Key | Optional on every POST; see Idempotency. |
X-Formfeed-Workspace | Only with a Formfeed session token (the app, or an AI agent connected through the MCP server): the id of the workspace the call is for. An API key ignores it, because a key belongs to one workspace already. |
X-Formfeed-Environment: test | Only the app's own test request (POST /renders with a member's session). An API key ignores it: its environment is its prefix. |
Response headers the API writes:
| Header | On |
|---|---|
X-Request-Id | Every response. |
Idempotent-Replayed: true | A stored answer to a repeated Idempotency-Key. |
RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset | Requests that count against the rate limit. |
Retry-After | Seconds to wait: on 429, on 503, and on every 202. |
Location | Every 202: the render's result for a render, /v1/jobs/{id} for a batch. |
X-Units-Charged, X-Units-Remaining, X-Render-Region, X-Render-Engine, X-Render-Deduplicated, Server-Timing | POST /renders and the PDF tools; see the render object. |
X-Render-Async-Fallback: true | A synchronous render that went to the background (the automatic fallback). |
X-Render-Id, X-Render-Pages, X-Render-Expires-At, X-Formfeed-PDF-UA | An answer that is the file itself (?output=binary). |
WWW-Authenticate | A 401 because no key was sent. |
Pagination
GET /renders, GET /templates and GET /files return one page and a cursor:
{
"data": [ … ],
"next_cursor": "2026-09-11 10:00:00.4821+00|0f3c9a2e-7b1d-4e5f-8a6c-3b2d1e0f9a8b"
}
Pass next_cursor back as ?cursor= to get the next page; it is null on the last one. Treat it as an opaque string and URL-encode it, since it contains spaces and other characters a URL does not allow. limit sets the page size: 1–100 for renders and files (a larger value answers 400 invalid_request), 1–200 for templates (a larger value is cut to 200), 50 by default. The SDKs' all() helpers (renders.all(), templates.all(), files.all()) follow the cursor for you.
curl "https://api-eu.formfeed.dev/v1/renders?status=failed&limit=100" -H "Authorization: Bearer $FORMFEED_API_KEY"
GET /renders takes the filters template (slug, tpl_… id or UUID), status (queued, rendering, succeeded, failed), environment (live, test), since and until (ISO 8601 date-times) and returns the newest first. Webhook endpoints, shared partials, template versions and channels come as one list in data, without a cursor.
Idempotency
Networks fail. Send an Idempotency-Key header with every POST, and retry with the same key when you do not know whether the first attempt arrived:
curl https://api-eu.formfeed.dev/v1/renders \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "Idempotency-Key: order-8812-invoice" \
-H "Content-Type: application/json" \
-d '{ "template": "invoice", "data": { … } }'
The first JSON answer to a key is stored for 24 hours per workspace. A repeat with the same key returns that answer again, with its status code, its body and Idempotent-Replayed: true, without rendering or charging a second time. Keys are up to 128 characters; use something tied to your own record (order-8812-invoice) or a UUID. The SDKs send a new one with every call that creates a render or a batch.
What to know before you rely on it:
- The key is not tied to the request. A key used again within 24 hours returns the first answer, even for a different body or a different endpoint. Use a new key for every new request.
- Errors below 500 are stored too. A request that answered
402 quota_exceededor422answers the same for 24 hours when it is sent with the same key. After you fixed the cause, send it with a new key. Server errors (5xx) and429 rate_limitedare not stored, so retrying them really retries. - The answer is stored when the first request has been answered. A retry sent while the first attempt is still running is not recognised and renders a second time. Wait for an answer, or for your client's timeout, before you retry; a synchronous render can take up to 60 seconds before it answers
202. - A replay is the answer as it was. A stored
202shows the render asqueued; askGET /renders/{id}for its current state. A replay carries the headers that describe the first answer too:LocationandRetry-Afterof a202,X-Units-*andX-Render-*.X-Units-Chargedsays what the first answer cost; the replay itself costs nothing. - Files are not replayed. An answer that was the file itself (
?output=binary) is not stored, so a retry renders again. A live render with the same content is then usually served from the dedup cache at no cost.
Retries
Retry 429 and 503 after the number of seconds in Retry-After, and network errors with exponential backoff, always with the same idempotency key. Do not retry other 4xx responses: they describe a problem with the request that a retry will not fix. The SDKs implement exactly this rule (up to 3 retries by default, waiting Retry-After, else 0.5 s doubling, at most 30 s).
From a browser
API keys belong on a server. The API refuses a key that arrives from a web page: a request with an Origin header answers 403 forbidden ("This API key may not be used from a browser origin"). Call Formfeed from your backend and hand the browser the result, for example the render's download_url. The one exception are the maker portals of Microsoft's Power Platform (Power Automate, Power Apps, Copilot Studio, Logic Apps in the Azure portal), whose connector proxy passes the portal's origin on; see Power Automate.
Versioning
The API version is in the path (/v1). Within a version, changes are additive: new endpoints, new optional request fields, new response fields, new error codes and new event types. Write clients that ignore fields they do not know, and treat an unknown code like its HTTP status. Breaking changes ship only as a new version. Nothing in /v1 is deprecated; a deprecation would be announced in the changelog before anything changes.
The OpenAPI 3 document is at https://api.formfeed.dev/v1/openapi.json (no key needed). Generate a client from it, import it into Postman or Insomnia, or point your API tooling at it.
Endpoints
All paths below start with /v1. "Scope" is what an API key needs (scopes); a key without it gets 403 forbidden.
| Endpoint | What it does | Scope |
|---|---|---|
POST /renders | Render a template, HTML or a URL | render:create |
GET /renders, GET /renders/{id} | List renders, get one (with a fresh download link) | render:read |
GET /renders/{id}/result | The result a 202 points to: 202 while the render runs, then the render, its file or its problem | render:read |
GET /renders/{id}/input | The stored request of a render, to reproduce it | render:input |
DELETE /renders/{id}/outputs | Delete the stored files of a render | file:delete |
POST /renders/batch, GET /jobs/{id} | Batch up to 1,000 renders, follow the job | render:create, render:read |
POST /pdf/merge, /pdf/protect, /pdf/watermark | Post-process PDF renders: merge, password, watermark | render:create |
POST /pdf/convert | Convert a Word, Excel, PowerPoint, OpenDocument, RTF or HTML file, or an office template's output, to a PDF render | render:create |
POST /pdf/info | Page count, page sizes and metadata of a PDF render | render:read |
GET /templates, GET /templates/{id} | List and read templates | template:read |
GET /templates/{id}/versions[/{which}], GET /templates/{id}/schema | Versions with their files, the data schema | template:read |
GET /templates/{id}/versions/{which}/file | Download the document of a Word or PowerPoint template version | template:read |
POST /templates/{id}/validate | Check a version with data without rendering, for free | template:read |
POST /templates, PUT/DELETE /templates/{id} | Create, update and archive templates | template:write |
POST /templates/{id}/versions, POST …/versions/{n}/publish | Create and publish versions | template:write |
GET /templates/{id}/channels | The template's release channels | template:read |
PUT/DELETE /templates/{id}/channels/{name}, POST …/channels/{name}/promote, POST …/rollback | Move, delete, promote and roll back release channels | template:write |
GET /files, GET /files/{id} | List and read the workspace's file library | file:read |
POST /files, DELETE /files/{id} | Upload to and delete from the file library | file:write, file:delete |
GET /brand, GET /partials, GET /partials/{name} | Read the organisation's brand kit and shared partials | template:read |
PUT/DELETE /partials/{name} | Create, update and delete shared partials | template:write |
GET/POST /webhooks, PUT/DELETE /webhooks/{id}, POST /webhooks/{id}/test | Manage webhook endpoints | webhook:manage |
POST /webhooks/listen, DELETE /webhooks/listen/{id} | Start and end a listen session | webhook:listen |
POST /webhooks/events/{id}/resend | Send a stored event again to an endpoint or a listen session | webhook:manage or webhook:listen |
GET /account, GET /usage | Workspace, plan and usage | account:read |
DELETE /workspaces/{id} | Delete a workspace of the organisation; its content is removed after 30 days | workspace:delete |
GET/PUT/DELETE /workspaces/{id}/storage, POST …/storage/test, POST …/storage/redeliver | Manage the workspace's own bucket and send failed copies again | none: an owner's or admin's session in the app |
POST /auth/device, POST /auth/device/token | The device login of formfeed login | none: no key yet |
POST /compat/apitemplate/* | apitemplate.io compatibility | render:create |
SDKs and tools
- TypeScript SDK:
npm install @formfeed/sdk, for Node, Deno, Bun and Cloudflare Workers, on the server side (not from a browser). - Python SDK:
pip install formfeed, sync and asyncio. - CLI:
npx formfeed, templates as code and renders from the terminal. - MCP server:
npx @formfeed/mcp, or the hosted endpointhttps://mcp.formfeed.dev, for AI agents.