Skip to main content

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 are snake_case. Uploads (library files, Word and PowerPoint templates, documents to convert) are multipart/form-data. A body that is not valid JSON, misses a required field or has a value of the wrong type answers 400 invalid_request, in most cases with an issues list that names each problem's path and a message.
  • Responses are JSON. Errors are application/problem+json (RFC 9457) with a stable code; 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 have evt_… 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 Z or +00:00 and 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 as request_id. Include it when you contact support.

Headers​

Request headers the API reads:

HeaderWhen
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-Typeapplication/json, or multipart/form-data for uploads.
Idempotency-KeyOptional on every POST; see Idempotency.
X-Formfeed-WorkspaceOnly 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: testOnly 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:

HeaderOn
X-Request-IdEvery response.
Idempotent-Replayed: trueA stored answer to a repeated Idempotency-Key.
RateLimit-Limit, RateLimit-Remaining, RateLimit-ResetRequests that count against the rate limit.
Retry-AfterSeconds to wait: on 429, on 503, and on every 202.
LocationEvery 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-TimingPOST /renders and the PDF tools; see the render object.
X-Render-Async-Fallback: trueA synchronous render that went to the background (the automatic fallback).
X-Render-Id, X-Render-Pages, X-Render-Expires-At, X-Formfeed-PDF-UAAn answer that is the file itself (?output=binary).
WWW-AuthenticateA 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_exceeded or 422 answers 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) and 429 rate_limited are 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 202 shows the render as queued; ask GET /renders/{id} for its current state. A replay carries the headers that describe the first answer too: Location and Retry-After of a 202, X-Units-* and X-Render-*. X-Units-Charged says 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.

EndpointWhat it doesScope
POST /rendersRender a template, HTML or a URLrender:create
GET /renders, GET /renders/{id}List renders, get one (with a fresh download link)render:read
GET /renders/{id}/resultThe result a 202 points to: 202 while the render runs, then the render, its file or its problemrender:read
GET /renders/{id}/inputThe stored request of a render, to reproduce itrender:input
DELETE /renders/{id}/outputsDelete the stored files of a renderfile:delete
POST /renders/batch, GET /jobs/{id}Batch up to 1,000 renders, follow the jobrender:create, render:read
POST /pdf/merge, /pdf/protect, /pdf/watermarkPost-process PDF renders: merge, password, watermarkrender:create
POST /pdf/convertConvert a Word, Excel, PowerPoint, OpenDocument, RTF or HTML file, or an office template's output, to a PDF renderrender:create
POST /pdf/infoPage count, page sizes and metadata of a PDF renderrender:read
GET /templates, GET /templates/{id}List and read templatestemplate:read
GET /templates/{id}/versions[/{which}], GET /templates/{id}/schemaVersions with their files, the data schematemplate:read
GET /templates/{id}/versions/{which}/fileDownload the document of a Word or PowerPoint template versiontemplate:read
POST /templates/{id}/validateCheck a version with data without rendering, for freetemplate:read
POST /templates, PUT/DELETE /templates/{id}Create, update and archive templatestemplate:write
POST /templates/{id}/versions, POST …/versions/{n}/publishCreate and publish versionstemplate:write
GET /templates/{id}/channelsThe template's release channelstemplate:read
PUT/DELETE /templates/{id}/channels/{name}, POST …/channels/{name}/promote, POST …/rollbackMove, delete, promote and roll back release channelstemplate:write
GET /files, GET /files/{id}List and read the workspace's file libraryfile:read
POST /files, DELETE /files/{id}Upload to and delete from the file libraryfile:write, file:delete
GET /brand, GET /partials, GET /partials/{name}Read the organisation's brand kit and shared partialstemplate:read
PUT/DELETE /partials/{name}Create, update and delete shared partialstemplate:write
GET/POST /webhooks, PUT/DELETE /webhooks/{id}, POST /webhooks/{id}/testManage webhook endpointswebhook:manage
POST /webhooks/listen, DELETE /webhooks/listen/{id}Start and end a listen sessionwebhook:listen
POST /webhooks/events/{id}/resendSend a stored event again to an endpoint or a listen sessionwebhook:manage or webhook:listen
GET /account, GET /usageWorkspace, plan and usageaccount:read
DELETE /workspaces/{id}Delete a workspace of the organisation; its content is removed after 30 daysworkspace:delete
GET/PUT/DELETE /workspaces/{id}/storage, POST …/storage/test, POST …/storage/redeliverManage the workspace's own bucket and send failed copies againnone: an owner's or admin's session in the app
POST /auth/device, POST /auth/device/tokenThe device login of formfeed loginnone: no key yet
POST /compat/apitemplate/*apitemplate.io compatibilityrender: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 endpoint https://mcp.formfeed.dev, for AI agents.