TypeScript SDK
@formfeed/sdk is a small, dependency-free client for the REST API, built on fetch and Web Crypto. It runs in Node 22+, Deno, Bun, Cloudflare Workers and other edge runtimes. It retries 429, 503 and network errors, sends an Idempotency-Key with every render and reuses it on its retries, so a retry after the first answer gets that answer instead of a second render (idempotency), and turns API problems into a typed FormfeedError.
Use it on a server. The API refuses an API key sent from a web page (a request with a browser's Origin header answers 403 forbidden), and a key in a browser bundle is public anyway.
npm install @formfeed/sdk
Set up a client
import { Formfeed } from '@formfeed/sdk';
const client = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY! });
| Option | Default | Notes |
|---|---|---|
apiKey | required | ff_live_… or ff_test_…. Keep it on the server. |
region | 'eu' | 'eu' (https://api-eu.formfeed.dev/v1) or 'us' (https://api-us.formfeed.dev/v1). |
baseUrl | region host | Full URL including /v1, used instead of the region's host. You need it only for an address other than the public API, such as one our support gives you. Wins over region. |
maxRetries | 3 | Retries on 429, 503 and network errors; 0 turns retrying off. |
timeoutMs | 120000 | Per HTTP attempt. An attempt that times out counts as a network error and is retried. |
fetch | global fetch | Inject your own for tests, proxies or custom agents. |
headers | none | Extra headers sent with every request. |
Most methods take an optional last argument { idempotencyKey?, signal? }: pass your own idempotency key to tie a render to a record in your system, and an AbortSignal to cancel. waitFor takes { timeoutMs?, intervalMs?, signal? } instead.
Retries and idempotency
A request that answers 429 or 503, or that fails on the network, is sent again up to maxRetries times. The SDK waits the seconds of the server's Retry-After, else 0.5 s, 1 s, 2 s and so on, plus up to 0.25 s of jitter, never more than 30 s. Other errors are not retried: a 4xx describes a problem with the request, and a 500 or 502 is reported as it is.
Every call that creates a render (renders.create, renders.batch and the PDF tools merge, protect, watermark and convert) carries an Idempotency-Key, a random one unless you pass idempotencyKey. The retries of one call reuse it, so the API renders and charges once and answers the retry with the stored first answer. A random key covers only the retries inside one call: to retry safely after your process restarted, pass a key of your own that you can send again, such as order-8812-invoice. See Idempotency.
Render
import { writeFile } from 'node:fs/promises';
const invoice = {
number: '2026-0042',
lines: [{ description: 'Consulting', qty: 8, price: 120 }],
};
const render = await client.renders.create(
{
template: 'invoice',
data: { invoice },
filename: `invoice-${invoice.number}.pdf`,
},
{ idempotencyKey: 'order-8812-invoice' },
);
// a sync render still running after 60 seconds comes back queued; wait for it
const done =
render.status === 'succeeded'
? render
: await client.renders.waitFor(render.id);
console.log(done.download_url, done.page_count, done.units);
await writeFile('invoice.pdf', await client.renders.download(done)); // Uint8Array of the file
create takes the same body as POST /renders: template, or html with engine, or url; version; data; output (pdf, png, jpg, webp, and docx or pptx for Word and PowerPoint templates; left out, the template decides); settings; locale; filename (the name the download gets, taken as it is: characters other than the letters A–Z, digits, _, ., -, (, ) and spaces become _, and the extension is added when it is missing); expires_in; access; mode; webhook_url and webhook_secret; meta; region; dedupe; canary_key; validate_data; post; storage. The API reference describes each field. version selects published (the default), a version number, latest (test keys only) or a release channel such as staging.
A failed render throws a FormfeedError. A sync render that has no answer after 60 seconds is not an error: it comes back with the status queued or rendering and finishes in the background (the automatic fallback), so check status before you download. renders.get(id) fetches a render again, with a fresh download link; renders.download(render) fetches the file from download_url and throws not_ready while the render has no output.
Typed data
Generate types for the data of your templates with formfeed types and include the generated formfeed.d.ts in your TypeScript program. renders.create and renders.batch then check data for every template the file knows:
import type { TemplateData } from '@formfeed/sdk';
// data must be InvoiceData: a misspelled or missing required field fails to compile
await client.renders.create({
template: 'invoice',
data: {
number: 'RE-2026-001',
status: 'sent',
customer: { name: 'Fennlor' },
items: [],
},
});
// the data type of one template, for your own code
const data: TemplateData<'invoice'> = await loadInvoice(orderId);
Unknown slugs, template ids and template names held in a string variable keep the untyped Record<string, unknown>, so the SDK behaves the same with or without the generated file.
Async renders and batches
const queued = await client.renders.create({
template: 'annual-report',
data,
mode: 'async',
});
const done = await client.renders.waitFor(queued.id, { timeoutMs: 120_000 }); // polls until succeeded or failed
const job = await client.renders.batch({
template: 'invoice',
items: orders.map((order) => ({
data: { invoice: order },
filename: `${order.number}.pdf`,
meta: { order_id: order.id },
})),
zip: true,
});
const finished = await client.jobs.waitFor(job.id);
console.log(
finished.status,
finished.succeeded,
finished.failed,
finished.zip_url,
);
renders.waitFor(id, { timeoutMs, intervalMs }) asks for the render every intervalMs (default 1 s) until it succeeded or failed and returns it either way; after timeoutMs (default 120 s) it throws a FormfeedError with the code timeout, while the render goes on. jobs.waitFor does the same for a batch every 2 s for up to 10 minutes and returns once the job is completed, partially_failed or failed. waitFor also handles the automatic async fallback of long sync renders. For anything that takes more than a few seconds in production, prefer webhooks over polling.
List renders and delete outputs
const page = await client.renders.list({
template: 'invoice',
status: 'failed',
limit: 20,
});
for (const r of page.data) console.log(r.id, r.error?.code);
// page.next_cursor → client.renders.list({ …, cursor: page.next_cursor })
// every render in a period; follows the cursor for you
const august = await client.renders.all({
since: '2026-08-01T00:00:00Z',
until: '2026-09-01T00:00:00Z',
});
await client.renders.deleteOutputs(render.id); // needs the file:delete scope
// the kept request of a render, to reproduce it (needs render:input; 410 render_input_expired when not kept)
const input = await client.renders.input(render.id);
PDF tools
import { readFile } from 'node:fs/promises';
// in the same render: steps run as merge, watermark, password
const contract = await client.renders.create({
template: 'contract',
data,
post: { watermark: { text: 'DRAFT' } },
});
// on renders you already have (a Render or its id); each returns a new render
const bundle = await client.pdf.merge([invoice, terms], {
filename: 'bundle.pdf',
});
const locked = await client.pdf.protect(bundle, {
user_password: 'open-sesame',
permissions: ['print'],
});
const info = await client.pdf.info(locked); // page_count, pages, encrypted, metadata; free
// office documents to PDF: an upload (Word, Excel, PowerPoint, OpenDocument, RTF or HTML, up to 20 MB, not kept)…
const report = await client.pdf.convert(
{ file: { data: await readFile('report.xlsx'), name: 'report.xlsx' } },
{ single_page_sheets: true, filename: 'report.pdf' },
);
// …or the output of a Word or PowerPoint template render
const offerPdf = await client.pdf.convert(offerRender, { page_ranges: '1-3' });
Starter plan and higher, 0.5 units per step on live keys; a conversion is counted like a PDF render. info is free on every plan. The output options of merge, protect, watermark and convert are those of a render: filename, expires_in, access, meta and storage. protect takes user_password, owner_password (random when left out) and permissions (print, copy, modify, annotate; everything not listed is forbidden); watermark takes text or image, opacity (default 0.2), rotation (degrees clockwise, default -45) and color. convert takes page_ranges, landscape and single_page_sheets besides the output options. See PDF tools.
File library
import { readFile } from 'node:fs/promises';
// the same name replaces the file everywhere; templates use {{ asset('brand/logo.png') }}
const logo = await client.files.upload({
data: await readFile('logo.png'),
name: 'brand/logo.png',
contentType: 'image/png',
});
console.log(logo.url);
const brand = await client.files.all({ prefix: 'brand/' });
await client.files.delete(logo);
// library files in post-processing: an image watermark and a PDF merged by name
await client.renders.create({
template: 'contract',
data,
post: { watermark: { image: 'draft.png' }, merge_after: ['terms.pdf'] },
});
file:read, file:write and file:delete scopes. Library files are served without a signature; see Files.
Brand kit and shared partials
const brand = await client.brand.get(); // colors, fonts, logo URLs, legal_footer, page_defaults
const partials = await client.partials.list(); // without source
const letterhead = await client.partials.get('letterhead');
const { partial, created } = await client.partials.put('letterhead', {
engine: 'jinja2',
source: letterhead.source!.replace('Fennlor', 'Fennlor Studio GmbH'),
base_version: letterhead.version, // 409 conflict when someone changed it in between
});
await client.partials.delete('old-footer');
Templates include a shared partial by name ({% include "letterhead" %}) and see the kit as brand; every render uses the current version of both. Reading needs template:read, writing template:write.
Templates
const templates = await client.templates.all({ kind: 'pdf' });
const schema = await client.templates.schema('invoice'); // JSON Schema of the expected data
const latest = await client.templates.versions.get('invoice', 'latest'); // html, css, settings, sample data, partials…
const draft = await client.templates.versions.create('invoice', {
...latest, // a new version replaces every file, so carry over what you do not change
html: (latest.html ?? '').replace('Invoice', 'Tax invoice'),
base_checksum: latest.checksum,
change_note: 'Rename heading',
});
await client.templates.versions.publish('invoice', draft.number); // { allowBreaking: true } publishes a breaking schema change
A new version holds exactly what you send: a field you leave out is empty in it (no CSS, no sample data, no partials), which is why the example spreads latest. With base_checksum, the API answers 409 conflict when another version was saved since the one you read. versions.get(slug, which) takes published (the default), latest, a number or a channel name; templates.schema(slug, { version }) returns the stored data schema, else one inferred from the sample data. Writing needs a key with template:write. See Templates API for versions and conflicts.
Word and PowerPoint templates
import { readFile, writeFile } from 'node:fs/promises';
// the file is sent as multipart; `data` is a Blob, File, Uint8Array (a Buffer) or ArrayBuffer
await client.templates.create({
name: 'Offer',
slug: 'offer',
kind: 'docx', // or 'pptx'
engine: 'jinja2',
file: { data: await readFile('offer.docx'), name: 'offer.docx' },
sample_data: { customer: { name: 'Olvarest GmbH' } },
publish: true,
});
// a new version with a new document (send settings and data along to keep them)…
await client.templates.versions.create('offer', {
file: { data: await readFile('offer-v2.docx'), name: 'offer.docx' },
change_note: 'New terms',
});
// …or without one: the latest version's document is kept, every other field is what you send
const current = await client.templates.versions.get('offer', 'latest');
await client.templates.versions.create('offer', {
settings: { ...current.settings, office: { output: 'pdf' } },
sample_data: current.sample_data,
data_sets: current.data_sets,
data_schema: current.data_schema,
i18n: current.i18n,
});
// the document of a version ('published', 'latest', a number or a channel name)
await writeFile(
'offer.docx',
await client.templates.versions.file('offer', 'latest'),
);
const filled = await client.renders.create({
template: 'offer',
output: 'docx',
data,
}); // or 'pdf'
A Word or PowerPoint template has no html, css, head or partials; its versions carry source_file (hash, size and format) instead. Live renders of these templates need the Starter plan or above; on the Free plan, test renders produce the marked PDF only, and docx or pptx throws a FormfeedError with the code test_output_unavailable.
Release channels
const { data, limits } = await client.templates.channels.list('invoice'); // published and named channels, renders of the last 24 hours
await client.templates.channels.set('invoice', 'staging', { version: 8 });
await client.templates.channels.set('invoice', 'published', {
version: 7,
canary: { version: 8, percent: 10 },
}); // Pro and above
await client.templates.channels.promote('invoice', 'published');
await client.templates.channels.rollback('invoice', 'published');
await client.templates.channels.delete('invoice', 'staging', { force: true });
const staging = await client.templates.versions.get('invoice', 'staging'); // the channel's version with its files
set, promote and rollback take { allowBreaking: true } as their last argument and return the Channel with its schema_check; a breaking change between stored schemas otherwise throws a FormfeedError with the code schema_breaking_change. set on published publishes. delete refuses a channel that renders of the last hour used (channel_in_use) unless you pass { force: true }. Named channels need the Starter plan (2 per template; 10 on Pro and Business, unlimited on Enterprise), canaries Pro and above; list returns the plan's allowance as limits. See Release channels.
Webhooks
import { parseWebhookEvent, verifyWebhookSignature } from '@formfeed/sdk';
// register an endpoint (needs webhook:manage); the secret is returned once
const endpoint = await client.webhooks.create({
url: 'https://fennlor.example/hooks/formfeed',
events: ['render.completed', 'render.failed'],
});
// in your handler: pass the raw body as a string, exactly as received
const event = await parseWebhookEvent(
process.env.FORMFEED_WEBHOOK_SECRET!,
request.headers.get('webhook-signature'),
await request.text(),
);
if (event.type === 'render.completed') {
/* event.data is the render */
}
parseWebhookEvent(secret, header, rawBody, options?) checks the Webhook-Signature header and returns the parsed event (id, type, created_at, workspace_id, data). It throws an Error (invalid webhook signature) when the signature does not match or its timestamp is more than five minutes from the current time; { toleranceSeconds } changes the five minutes. verifyWebhookSignature takes the same arguments and returns a boolean instead. The Webhooks page has a complete Express handler.
webhooks.create, list, update(id, patch), delete(id) and test(id) manage endpoints (webhook:manage scope). Endpoints take environments (["live"], ["test"] or both, the default). client.webhooks.resend(eventId, endpointId) sends a stored event again to an endpoint or a listen session, and client.webhooks.listen.start({ events, live }) / end(id) manage the sessions formfeed listen uses; the WebSocket itself is up to the caller.
Usage and account
const usage = await client.account.usage(); // current period
const august = await client.account.usage('2026-08'); // the period that started in August 2026
console.log(usage.used, usage.included, usage.by_template);
const account = await client.account.get(); // workspace, plan, limits
await client.workspaces.delete(previewWorkspaceId); // needs the workspace:delete scope
Units are counted per organisation, in billing periods that renew on the day the subscription started (calendar months on the Free plan without a subscription). usage('2026-08') returns the period that started in August 2026. Both need account:read.
Errors
import { FormfeedError } from '@formfeed/sdk';
try {
await client.renders.create({ template: 'missing' });
} catch (e) {
if (e instanceof FormfeedError) {
console.error(e.code, e.status, e.message, e.requestId); // template_not_found 404 …
console.error(e.problem?.location); // line and column for template errors
}
}
e.code is the problem code of the API's answer, and e.status its HTTP status; e.problem is the full problem body and e.requestId the X-Request-Id to quote to support. Failures on the client's side have the status 0 and these codes:
| Code | When |
|---|---|
network_error | The request could not be sent or timed out (timeoutMs) on every attempt |
timeout | renders.waitFor or jobs.waitFor ran out of time; the render or job goes on |
not_ready | renders.download of a render that has no output yet, or failed |
download_failed | The download link answered an error (the status is the link's) |
invalid_request | The client was created without apiKey, or without a fetch |
An error answer without a problem code gets http_<status>, such as http_502.
Types
The request and response types are exported (Render, RenderRequest, Job, Template, TemplateVersion, TemplateKind, OutputFormat, OfficeTemplateCreate, OfficeVersionCreate, OfficeFileUpload, ConvertOptions, Channel, ChannelList, SchemaCheck, WebhookEndpoint, LibraryFile, Usage, Problem, …). They are written by hand to match the API reference. FormfeedTemplates, TemplateData and TypedRenderRequest are the hooks for typed data.