TypeScript-SDK
@formfeed/sdk ist ein kleiner Client ohne Abhängigkeiten für die REST-API, gebaut auf fetch und Web Crypto. Er läuft in Node 22+, Deno, Bun, Cloudflare Workers und anderen Edge-Runtimes. Er wiederholt 429, 503 und Netzwerkfehler, sendet mit jedem Rendering einen Idempotency-Key und nutzt ihn bei seinen Wiederholungen wieder, sodass eine Wiederholung nach der ersten Antwort diese Antwort bekommt statt eines zweiten Renderings (Idempotenz), und macht aus API-Problemen einen typisierten FormfeedError.
Verwenden Sie ihn auf einem Server. Die API weist einen API-Schlüssel ab, der von einer Webseite kommt (eine Anfrage mit dem Origin-Header eines Browsers wird mit 403 forbidden beantwortet), und ein Schlüssel in einem Browser-Bundle ist ohnehin öffentlich.
npm install @formfeed/sdk
Client einrichten
import { Formfeed } from '@formfeed/sdk';
const client = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY! });
| Option | Standard | Hinweise |
|---|---|---|
apiKey | erforderlich | ff_live_… oder ff_test_…. Bewahren Sie ihn auf dem Server auf. |
region | 'eu' | 'eu' (https://api-eu.formfeed.dev/v1) oder 'us' (https://api-us.formfeed.dev/v1). |
baseUrl | Host der Region | Vollständige URL inklusive /v1, statt des Hosts der Region. Nur nötig für eine andere Adresse als die öffentliche API, etwa eine, die Ihnen unser Support nennt. Hat Vorrang vor region. |
maxRetries | 3 | Wiederholungen bei 429, 503 und Netzwerkfehlern; 0 schaltet das Wiederholen ab. |
timeoutMs | 120000 | Pro HTTP-Versuch. Ein Versuch, der in den Timeout läuft, zählt als Netzwerkfehler und wird wiederholt. |
fetch | globales fetch | Injizieren Sie ein eigenes für Tests, Proxys oder eigene Agents. |
headers | keine | Zusätzliche Header, die mit jeder Anfrage gesendet werden. |
Die meisten Methoden nehmen ein optionales letztes Argument { idempotencyKey?, signal? }: Übergeben Sie einen eigenen Idempotenz-Schlüssel, um ein Rendering an einen Datensatz in Ihrem System zu binden, und ein AbortSignal zum Abbrechen. waitFor nimmt stattdessen { timeoutMs?, intervalMs?, signal? }.
Wiederholungen und Idempotenz
Eine Anfrage, die mit 429 oder 503 beantwortet wird oder im Netzwerk scheitert, wird bis zu maxRetries-mal erneut gesendet. Das SDK wartet die Sekunden aus dem Retry-After des Servers, sonst 0,5 s, 1 s, 2 s und so weiter, dazu bis zu 0,25 s Zufall, nie mehr als 30 s. Andere Fehler werden nicht wiederholt: Ein 4xx beschreibt ein Problem der Anfrage, und ein 500 oder 502 wird gemeldet, wie es ist.
Jeder Aufruf, der ein Rendering erzeugt (renders.create, renders.batch und die PDF-Tools merge, protect, watermark und convert), trägt einen Idempotency-Key, einen zufälligen, wenn Sie kein idempotencyKey übergeben. Die Wiederholungen eines Aufrufs verwenden ihn erneut, also rendert und berechnet die API nur einmal und beantwortet die Wiederholung mit der gespeicherten ersten Antwort. Ein zufälliger Schlüssel deckt nur die Wiederholungen innerhalb eines Aufrufs ab: Um nach einem Neustart Ihres Prozesses sicher zu wiederholen, übergeben Sie einen eigenen Schlüssel, den Sie erneut senden können, etwa order-8812-invoice. Siehe Idempotenz.
Rendern
import { writeFile } from 'node:fs/promises';
const invoice = {
number: '2026-0042',
lines: [{ description: 'Beratung', qty: 8, price: 120 }],
};
const render = await client.renders.create(
{
template: 'invoice',
data: { invoice },
filename: `invoice-${invoice.number}.pdf`,
},
{ idempotencyKey: 'order-8812-invoice' },
);
// ein synchrones Rendering, das nach 60 Sekunden noch läuft, kommt eingereiht zurück; darauf warten
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 der Datei
create nimmt denselben Body wie POST /renders: template, oder html mit engine, oder url; version; data; output (pdf, png, jpg, webp sowie docx oder pptx für Word- und PowerPoint-Vorlagen; ohne Angabe entscheidet die Vorlage); settings; locale; filename (der Name, den der Download bekommt, so übernommen, wie er ist: andere Zeichen als die Buchstaben A–Z, Ziffern, _, ., -, (, ) und Leerzeichen werden zu _, und die Endung wird ergänzt, wenn sie fehlt); expires_in; access; mode; webhook_url und webhook_secret; meta; region; dedupe; canary_key; validate_data; post; storage. Die API-Referenz beschreibt jedes Feld. version wählt published (der Standard), eine Versionsnummer, latest (nur mit Test-Schlüsseln) oder einen Release Channel wie staging.
Ein fehlgeschlagenes Rendering wirft einen FormfeedError. Ein synchrones Rendering, das nach 60 Sekunden keine Antwort hat, ist kein Fehler: Es kommt mit dem Status queued oder rendering zurück und wird im Hintergrund fertig (der automatische Fallback); prüfen Sie also status, bevor Sie herunterladen. renders.get(id) holt ein Rendering erneut, mit einem frischen Download-Link; renders.download(render) lädt die Datei von download_url und wirft not_ready, solange das Rendering keine Ausgabe hat.
Typisierte Daten
Erzeugen Sie mit formfeed types Typen für die Daten Ihrer Vorlagen und nehmen Sie die erzeugte formfeed.d.ts in Ihr TypeScript-Programm auf. Danach prüfen renders.create und renders.batch die data jeder Vorlage, die die Datei kennt:
import type { TemplateData } from '@formfeed/sdk';
// data muss InvoiceData sein: ein falsch geschriebenes oder fehlendes Pflichtfeld kompiliert nicht
await client.renders.create({
template: 'invoice',
data: {
number: 'RE-2026-001',
status: 'sent',
customer: { name: 'Fennlor' },
items: [],
},
});
// der Datentyp einer Vorlage, für Ihren eigenen Code
const data: TemplateData<'invoice'> = await loadInvoice(orderId);
Unbekannte Slugs, Vorlagen-IDs und Vorlagennamen in einer string-Variablen behalten das untypisierte Record<string, unknown>; das SDK verhält sich mit und ohne erzeugte Datei gleich.
Asynchrone Renderings und Batches
const queued = await client.renders.create({
template: 'annual-report',
data,
mode: 'async',
});
const done = await client.renders.waitFor(queued.id, { timeoutMs: 120_000 }); // fragt ab, bis succeeded oder 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 }) fragt das Rendering alle intervalMs (Standard 1 s) ab, bis es erfolgreich war oder fehlschlug, und liefert es in beiden Fällen; nach timeoutMs (Standard 120 s) wirft es einen FormfeedError mit dem Code timeout, während das Rendering weiterläuft. jobs.waitFor tut dasselbe für einen Batch alle 2 s bis zu 10 Minuten lang und kehrt zurück, sobald der Job completed, partially_failed oder failed ist. waitFor behandelt auch den automatischen Wechsel auf asynchron bei langen synchronen Renderings. Für alles, was in Produktion länger als ein paar Sekunden dauert, sind Webhooks dem Abfragen vorzuziehen.
Renderings auflisten und Ausgaben löschen
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 })
// alle Renderings eines Zeitraums; folgt dem Cursor für Sie
const august = await client.renders.all({
since: '2026-08-01T00:00:00Z',
until: '2026-09-01T00:00:00Z',
});
await client.renders.deleteOutputs(render.id); // braucht den Scope file:delete
// die aufbewahrte Anfrage eines Renderings, um es nachzustellen (braucht render:input; 410 render_input_expired, wenn nicht aufbewahrt)
const input = await client.renders.input(render.id);
PDF-Tools
import { readFile } from 'node:fs/promises';
// im selben Rendering: die Schritte laufen in der Reihenfolge merge, watermark, password
const contract = await client.renders.create({
template: 'contract',
data,
post: { watermark: { text: 'ENTWURF' } },
});
// auf vorhandenen Renderings (ein Render oder seine ID); jeder Aufruf liefert ein neues Rendering
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; kostenlos
// Office-Dokumente in PDF: ein Upload (Word, Excel, PowerPoint, OpenDocument, RTF oder HTML, bis 20 MB, nicht gespeichert) …
const report = await client.pdf.convert(
{ file: { data: await readFile('report.xlsx'), name: 'report.xlsx' } },
{ single_page_sheets: true, filename: 'report.pdf' },
);
// … oder die Ausgabe eines Renderings einer Word- oder PowerPoint-Vorlage
const offerPdf = await client.pdf.convert(offerRender, { page_ranges: '1-3' });
Ab dem Starter-Plan, 0,5 Einheiten pro Schritt mit Live-Schlüsseln; eine Umwandlung zählt wie ein PDF-Rendering. info ist in jedem Plan kostenlos. Die Ausgabeoptionen von merge, protect, watermark und convert sind die eines Renderings: filename, expires_in, access, meta und storage. protect nimmt user_password, owner_password (zufällig, wenn weggelassen) und permissions (print, copy, modify, annotate; alles nicht Genannte ist verboten); watermark nimmt text oder image, opacity (Standard 0,2), rotation (Grad im Uhrzeigersinn, Standard -45) und color. convert nimmt neben den Ausgabeoptionen page_ranges, landscape und single_page_sheets. Siehe PDF-Tools.
Dateibibliothek
import { readFile } from 'node:fs/promises';
// derselbe Name ersetzt die Datei überall; Vorlagen verwenden {{ 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);
// Dateien der Bibliothek im Post-Processing: ein Bild-Wasserzeichen und ein PDF, über den Namen angehängt
await client.renders.create({
template: 'contract',
data,
post: { watermark: { image: 'draft.png' }, merge_after: ['terms.pdf'] },
});
Scopes file:read, file:write und file:delete. Dateien der Bibliothek werden ohne Signatur ausgeliefert; siehe Dateien.
Brand Kit und geteilte Partials
const brand = await client.brand.get(); // colors, fonts, Logo-URLs, legal_footer, page_defaults
const partials = await client.partials.list(); // ohne 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, wenn es inzwischen jemand geändert hat
});
await client.partials.delete('old-footer');
Vorlagen binden ein geteiltes Partial über seinen Namen ein ({% include "letterhead" %}) und sehen das Kit als brand; jedes Rendering verwendet die aktuelle Version von beidem. Lesen braucht template:read, Schreiben template:write.
Vorlagen
const templates = await client.templates.all({ kind: 'pdf' });
const schema = await client.templates.schema('invoice'); // JSON-Schema der erwarteten Daten
const latest = await client.templates.versions.get('invoice', 'latest'); // html, css, settings, Beispieldaten, Partials …
const draft = await client.templates.versions.create('invoice', {
...latest, // eine neue Version ersetzt jede Datei, also übernehmen Sie, was Sie nicht ändern
html: (latest.html ?? '').replace('Invoice', 'Tax invoice'),
base_checksum: latest.checksum,
change_note: 'Überschrift umbenennen',
});
await client.templates.versions.publish('invoice', draft.number); // { allowBreaking: true } veröffentlicht eine brechende Schema-Änderung
Eine neue Version enthält genau das, was Sie senden: Ein weggelassenes Feld ist in ihr leer (kein CSS, keine Beispieldaten, keine Partials); deshalb übernimmt das Beispiel latest vollständig. Mit base_checksum antwortet die API mit 409 conflict, wenn seit der gelesenen Version eine andere gespeichert wurde. versions.get(slug, which) nimmt published (der Standard), latest, eine Nummer oder einen Channel-Namen; templates.schema(slug, { version }) liefert das gespeicherte Datenschema, sonst eines, das aus den Beispieldaten abgeleitet ist. Schreibzugriffe brauchen einen Schlüssel mit template:write. Versionen und Konflikte beschreibt die Vorlagen-API.
Word- und PowerPoint-Vorlagen
import { readFile, writeFile } from 'node:fs/promises';
// die Datei geht als Multipart; `data` ist ein Blob, File, Uint8Array (ein Buffer) oder ArrayBuffer
await client.templates.create({
name: 'Angebot',
slug: 'offer',
kind: 'docx', // oder 'pptx'
engine: 'jinja2',
file: { data: await readFile('offer.docx'), name: 'offer.docx' },
sample_data: { customer: { name: 'Olvarest GmbH' } },
publish: true,
});
// eine neue Version mit neuem Dokument (Einstellungen und Daten mitsenden, um sie zu behalten) …
await client.templates.versions.create('offer', {
file: { data: await readFile('offer-v2.docx'), name: 'offer.docx' },
change_note: 'Neue Konditionen',
});
// … oder ohne: Das Dokument der letzten Version bleibt, jedes andere Feld ist das, was Sie senden
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,
});
// das Dokument einer Version ('published', 'latest', eine Nummer oder ein Channel-Name)
await writeFile(
'offer.docx',
await client.templates.versions.file('offer', 'latest'),
);
const filled = await client.renders.create({
template: 'offer',
output: 'docx',
data,
}); // oder 'pdf'
Eine Word- oder PowerPoint-Vorlage hat kein html, css, head und keine partials; ihre Versionen tragen stattdessen source_file (Hash, Größe und Format). Live-Renderings dieser Vorlagen brauchen den Starter-Plan oder höher; im Free-Plan liefern Test-Renderings nur das markierte PDF, und docx oder pptx wirft einen FormfeedError mit dem Code test_output_unavailable.
Release Channels
const { data, limits } = await client.templates.channels.list('invoice'); // published und benannte Channels, Renderings der letzten 24 Stunden
await client.templates.channels.set('invoice', 'staging', { version: 8 });
await client.templates.channels.set('invoice', 'published', {
version: 7,
canary: { version: 8, percent: 10 },
}); // ab Pro
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'); // die Version des Channels mit ihren Dateien
set, promote und rollback nehmen { allowBreaking: true } als letztes Argument und liefern den Channel mit seinem schema_check; eine brechende Änderung zwischen gespeicherten Schemas wirft sonst einen FormfeedError mit dem Code schema_breaking_change. set auf published veröffentlicht. delete weist einen Channel ab, den Renderings der letzten Stunde verwendet haben (channel_in_use), außer Sie übergeben { force: true }. Benannte Channels brauchen den Starter-Plan (2 je Vorlage; 10 bei Pro und Business, unbegrenzt bei Enterprise), Canaries Pro oder höher; list liefert das Kontingent des Plans als limits. Siehe Release Channels.
Webhooks
import { parseWebhookEvent, verifyWebhookSignature } from '@formfeed/sdk';
// einen Endpunkt registrieren (braucht webhook:manage); das Secret wird einmal zurückgegeben
const endpoint = await client.webhooks.create({
url: 'https://fennlor.example/hooks/formfeed',
events: ['render.completed', 'render.failed'],
});
// in Ihrem Handler: den rohen Body als String übergeben, genau wie empfangen
const event = await parseWebhookEvent(
process.env.FORMFEED_WEBHOOK_SECRET!,
request.headers.get('webhook-signature'),
await request.text(),
);
if (event.type === 'render.completed') {
/* event.data ist das Rendering */
}
parseWebhookEvent(secret, header, rawBody, options?) prüft den Header Webhook-Signature und liefert das geparste Ereignis (id, type, created_at, workspace_id, data). Es wirft einen Error (invalid webhook signature), wenn die Signatur nicht passt oder ihr Zeitstempel mehr als fünf Minuten von der aktuellen Zeit abweicht; { toleranceSeconds } ändert die fünf Minuten. verifyWebhookSignature nimmt dieselben Argumente und liefert stattdessen einen Wahrheitswert. Die Seite Webhooks enthält einen vollständigen Express-Handler.
webhooks.create, list, update(id, patch), delete(id) und test(id) verwalten Endpunkte (Scope webhook:manage). Endpunkte nehmen environments an (["live"], ["test"] oder beide, der Standard). client.webhooks.resend(eventId, endpointId) sendet ein gespeichertes Ereignis erneut an einen Endpunkt oder eine Listen-Sitzung, und client.webhooks.listen.start({ events, live }) / end(id) verwalten die Sitzungen, die formfeed listen verwendet; den WebSocket selbst übernimmt der Aufrufer.
Verbrauch und Konto
const usage = await client.account.usage(); // aktuelle Periode
const august = await client.account.usage('2026-08'); // die Periode, die im August 2026 begann
console.log(usage.used, usage.included, usage.by_template);
const account = await client.account.get(); // Workspace, Plan, Limits
await client.workspaces.delete(previewWorkspaceId); // braucht den Scope workspace:delete
Einheiten zählen je Organisation, in Abrechnungsperioden, die sich am Tag des Abschlusses des Abonnements erneuern (im Free-Plan ohne Abonnement Kalendermonate). usage('2026-08') liefert die Periode, die im August 2026 begann. Beides braucht account:read.
Fehler
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); // Zeile und Spalte bei Vorlagenfehlern
}
}
e.code ist der Problemcode der API-Antwort und e.status ihr HTTP-Status; e.problem ist der vollständige Problem-Body und e.requestId die X-Request-Id, die Sie dem Support nennen. Fehler auf der Seite des Clients haben den Status 0 und diese Codes:
| Code | Wann |
|---|---|
network_error | Die Anfrage ließ sich bei keinem Versuch senden oder lief jedes Mal in den Timeout (timeoutMs) |
timeout | renders.waitFor oder jobs.waitFor hatte keine Zeit mehr; Rendering oder Job laufen weiter |
not_ready | renders.download eines Renderings, das noch keine Ausgabe hat oder fehlschlug |
download_failed | Der Download-Link antwortete mit einem Fehler (der Status ist der des Links) |
invalid_request | Der Client wurde ohne apiKey oder ohne fetch erzeugt |
Eine Fehlerantwort ohne Problemcode bekommt http_<status>, etwa http_502.
Typen
Die Anfrage- und Antworttypen werden exportiert (Render, RenderRequest, Job, Template, TemplateVersion, TemplateKind, OutputFormat, OfficeTemplateCreate, OfficeVersionCreate, OfficeFileUpload, ConvertOptions, Channel, ChannelList, SchemaCheck, WebhookEndpoint, LibraryFile, Usage, Problem, …). Sie sind von Hand geschrieben und entsprechen der API-Referenz. FormfeedTemplates, TemplateData und TypedRenderRequest sind die Anknüpfungspunkte für typisierte Daten.