Zum Hauptinhalt springen

Eigener Speicher

Mit eigenem Speicher (Bring your own storage) kopiert Formfeed jedes Dokument, das ein Workspace erzeugt, in einen Bucket, der Ihnen gehört: bei Amazon S3, Cloudflare R2 oder einem beliebigen S3-kompatiblen Objektspeicher. Ihr Archiv, Ihr Dokumentenmanagement oder Ihre Datenpipeline findet jede Datei dort, wo sie sie erwartet, und Ihr Code braucht keinen Download-Schritt.

Der Bucket ist ein zusätzliches Ziel, kein Ersatz für den Speicher von Formfeed:

  • Formfeed behält die eigene Kopie für die Aufbewahrungsfrist des Workspace, genau wie ohne Verbindung. download_url, das CDN, die PDF-Tools und die Zips von Batches funktionieren also wie bisher. Soll Formfeed so wenig wie möglich aufbewahren, setzen Sie unter Einstellungen die Aufbewahrung der Ausgaben (Tage) auf 1.
  • Die Kopie entsteht im Hintergrund, nach dem Rendering. Sie macht ein Rendering nie langsamer und lässt es nie fehlschlagen. Eine synchrone Antwort meldet deshalb storage.status: "pending"; das Ereignis storage.delivered sagt Ihnen, wann das Objekt in Ihrem Bucket liegt.
  • Formfeed schreibt nur. Es sendet PutObject-Anfragen und sonst nichts: Es liest, listet und löscht nie etwas in Ihrem Bucket. Das Löschen der Ausgaben eines Renderings, eines Workspace oder der Organisation lässt die Objekte in Ihrem Bucket unberührt.

Eigener Speicher gehört zum Pro-Plan und den Plänen darüber. Unterhalb von Pro antworten das Einrichten, das Testen und die erneute Zustellung einer Verbindung mit 403 forbidden und feature: "byo_storage". Wechselt eine Organisation in einen Plan ohne die Funktion, bleibt die Verbindung bestehen, erscheint aber als pausiert (paused_reason: "plan"): Dokumente werden nicht mehr kopiert, noch wartende Kopien enden mit dem Grund plan, und Renderings laufen ohne Fehler weiter. Dokumente, die in dieser Zeit gerendert werden, werden nicht festgehalten und lassen sich später nicht erneut zustellen. Zurück auf Pro oder darüber ist die Verbindung von selbst wieder aktiv.

Bucket verbinden​

Inhaber und Admins verbinden einen Bucket in der App, unter Einstellungen auf der Karte Eigener Speicher. Die Karte gehört zum aktuellen Workspace, und ein Workspace hat höchstens eine Verbindung.

AnbieterWas Sie eingebenEndpunkt
Amazon S3 (s3)Die Region des Buckets, zum Beispiel eu-central-1Abgeleitet: https://s3.<region>.amazonaws.com
Cloudflare R2 (r2)Die Konto-ID und der Rechtsraum: default oder eu für einen Bucket in Cloudflares EU-JurisdiktionAbgeleitet: https://<account id>.r2.cloudflarestorage.com, für eu https://<account id>.eu.r2.cloudflarestorage.com
S3-kompatibel (s3_compatible)Der Endpunkt des Dienstes (nur https); optional die Region, mit der er signiert (us-east-1, wenn leer), und Bucket im Pfad adressierenWie eingegeben

S3-kompatibel umfasst Hetzner Object Storage, IONOS, Scaleway, Wasabi, Backblaze B2, MinIO und Google Cloud Storage (mit HMAC-Schlüsseln und dem Endpunkt https://storage.googleapis.com). Tragen Sie den S3-Endpunkt ein, den Ihr Anbieter für den Standort des Buckets angibt, zum Beispiel https://fsn1.your-objectstorage.com für Hetzner in Falkenstein. Setzen Sie das Häkchen bei Bucket im Pfad adressieren, wenn der Dienst den Bucket-Namen im Pfad statt im Hostnamen erwartet, wie viele MinIO-Server. Azure Blob Storage ist nicht S3-kompatibel und wird nicht unterstützt.

Neben dem Anbieter hat eine Verbindung:

FeldBedeutung
BucketDer Name des Buckets: 3 bis 63 Buchstaben, Ziffern, Punkte, Bindestriche oder Unterstriche, am Anfang und Ende ein Buchstabe oder eine Ziffer
PräfixOptionaler Ordner, unter den jedes Objekt geschrieben wird, zum Beispiel formfeed/, höchstens 200 Zeichen. Führende und doppelte Schrägstriche werden entfernt und ein abschließendes / ergänzt; Segmente . und .. werden abgelehnt
Vorlage für ObjektschlüsselWie der Schlüssel jedes Objekts unterhalb des Präfixes gebildet wird, siehe Objektschlüssel. Standard: {yyyy}/{mm}/{render_id}/{filename}. Höchstens 512 Byte, nur die dort aufgeführten Platzhalter, kein / am Anfang oder Ende
UmgebungenWessen Dokumente kopiert werden: live (der Standard) für Renderings mit Live-Schlüsseln; mit test auch Test-Renderings
Öffentliche Basis-URLOptionale https-Adresse, unter der Sie den Bucket ausliefern, zum Beispiel Ihre eigene Domain davor, höchstens 500 Zeichen, ohne Zugangsdaten, Query oder Fragment. Die url jeder Kopie wird daraus gebildet; Formfeed prüft nicht, ob sie antwortet
Zugriffsschlüssel-ID, geheimer ZugriffsschlüsselDie Zugangsdaten, mit denen Formfeed schreibt, immer zusammen eingegeben

Testen und verbinden (oder, wenn schon verbunden, Testen und speichern) testet zuerst: Formfeed schreibt mit den eingegebenen Zugangsdaten eine kleine Textdatei, <prefix>.formfeed/connection-test.txt (die Zeile Formfeed connection test und die Uhrzeit), und speichert die Verbindung nur, wenn das gelingt. Schlägt der Test fehl, zeigt die Karte die Antwort des Anbieters, etwa AccessDenied oder NoSuchBucket, und nichts wird gespeichert: Eine neue Verbindung entsteht nicht, eine bestehende bleibt, wie sie war. Die API antwortet mit 422 storage_test_failed. Die Testdatei bleibt im Bucket, weil Formfeed dort nie etwas löscht.

Die Zugangsdaten werden verschlüsselt in Supabase Vault gespeichert, und nur der Render-Worker entschlüsselt sie, um zu schreiben. App und API zeigen sie nie wieder an, nur die letzten vier Zeichen der Zugriffsschlüssel-ID. Um Bucket, Präfix, Vorlage für Objektschlüssel, Umgebungen oder öffentliche Basis-URL zu ändern, speichern Sie, ohne den geheimen Zugriffsschlüssel erneut einzugeben; die Änderung wird mit dem gespeicherten Schlüssel getestet. Eine Änderung, die den Endpunkt verschiebt (ein anderer Anbieter, eine andere AWS-Region, ein anderes R2-Konto oder ein anderer Rechtsraum, ein anderer S3-kompatibler Endpunkt), braucht den Schlüssel erneut, und ein neuer Schlüssel wird immer getestet, bevor er den alten ersetzt.

Endpunkte S3-kompatibler Dienste sind nur die Adresse des Dienstes, ohne Pfad und ohne den Bucket. Sie müssen https verwenden, dürfen keine Zugangsdaten in der URL tragen und nicht auf eine private oder interne Adresse zeigen. Der Render-Worker prüft bei jedem Verbindungsaufbau jede Adresse, in die der Hostname aufgelöst wird. Eine DNS-Änderung kann Ihren Endpunkt also nicht in eine Adresse innerhalb eines privaten Netzes verwandeln.

Trennen auf derselben Karte entfernt die Verbindung und ihre Zugangsdaten. Noch wartende Kopien enden mit dem Grund connection_deleted und lösen kein Ereignis aus; Objekte, die schon in Ihrem Bucket liegen, bleiben.

Minimale Berechtigungen​

Formfeed muss Objekte unterhalb des Präfixes schreiben können, mehr nicht. Geben Sie ihm einen eigenen Schlüssel, der genau das darf.

Amazon S3​

Legen Sie für Formfeed einen IAM-Benutzer mit einem Zugriffsschlüssel und ausschließlich dieser Richtlinie an. Ersetzen Sie fennlor-docs durch Ihren Bucket und formfeed/ durch Ihr Präfix; ohne Präfix lautet die Ressource arn:aws:s3:::fennlor-docs/*.

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "FormfeedWritesBelowThePrefix",
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::fennlor-docs/formfeed/*"
}
]
}

Der Verbindungstest schreibt formfeed/.formfeed/connection-test.txt, was dieselbe Anweisung abdeckt. Objekte werden ohne ACL-Header geschrieben, die Einstellungen des Buckets entscheiden also, wer sie lesen darf, und die serverseitige Verschlüsselung folgt der Standardverschlüsselung des Buckets. Ist das SSE-KMS mit einem kundenverwalteten Schlüssel, erlauben Sie dem Benutzer zusätzlich kms:GenerateDataKey auf diesem Schlüssel, sonst scheitert jeder Schreibvorgang mit AccessDenied.

Cloudflare R2​

Legen Sie im Cloudflare-Dashboard ein R2-API-Token mit der Berechtigung Object Read & Write an, beschränkt auf diesen einen Bucket, und tragen Sie seine Zugriffsschlüssel-ID und seinen geheimen Zugriffsschlüssel ein. R2 kennt keine reine Schreibberechtigung, das Token könnte also auch lesen; Formfeed schreibt trotzdem nur. Die Konto-ID steht in der R2-Übersicht. Wählen Sie beim Rechtsraum eu für einen Bucket, der in Cloudflares EU-Jurisdiktion angelegt wurde: Ein Bucket, der im falschen Rechtsraum angesprochen wird, antwortet mit NoSuchBucket.

Hetzner und andere S3-kompatible Dienste​

Legen Sie in der Konsole Ihres Anbieters einen Zugriffsschlüssel für Formfeed an, beschränkt auf den Bucket, wo der Dienst das erlaubt. Unterstützt der Dienst Bucket- oder Benutzerrichtlinien, erlauben Sie dem Schlüssel nichts außer s3:PutObject auf arn:aws:s3:::<bucket>/<prefix>*, wie in der AWS-Richtlinie oben.

  • Hetzner: S3-Zugangsdaten gelten für jeden Bucket ihres Projekts, mit Lese- und Schreibzugriff. Legen Sie den Bucket in ein eigenes Projekt, damit der Schlüssel, den Formfeed erhält, sonst nichts öffnet; eine Bucket-Richtlinie kann ihn weiter einschränken, und Hetzners Dokumentation zu S3-Zugangsdaten zeigt, wie eine Richtlinie einen einzelnen Schlüssel benennt.
  • Google Cloud Storage: Legen Sie den HMAC-Schlüssel für ein Dienstkonto an und geben Sie diesem Konto storage.objects.create und storage.objects.delete auf dem Bucket, zum Beispiel über eine benutzerdefinierte Rolle. Cloud Storage behandelt das Ersetzen eines Objekts als Löschen, und der Verbindungstest ersetzt seine Testdatei jedes Mal; mit der Rolle Storage Object Creator allein scheitert deshalb der zweite Test. Formfeed löscht trotzdem nie etwas.

Objektschlüssel​

Jede Kopie wird nach <prefix><key> geschrieben. Der Schlüssel entsteht aus der Vorlage für Objektschlüssel der Verbindung, sofern die Anfrage nicht einen eigenen nennt. Die Standardvorlage für Objektschlüssel ist {yyyy}/{mm}/{render_id}/{filename}.

PlatzhalterWert
{workspace}Der Slug des Workspace
{template}Der Slug der Vorlage
{environment}live oder test
{render_id}Die ID des Renderings, rnd_…
{job_id}Die ID des Batches, job_…, für die Einträge eines Batches und sein Zip
{yyyy}, {mm}, {dd}Jahr, Monat und Tag, an dem das Rendering oder der Batch angelegt wurde, in UTC
{filename}Der gespeicherte Dateiname: der filename der Anfrage, ohne ihn document.pdf (oder .png, .docx, …), für das Zip eines Batches outputs.zip. Zeichen außer Buchstaben, Ziffern, ., -, _, (, ) und Leerzeichen werden zu _, und die Endung wird ergänzt, wenn sie dem Namen fehlt

Ein Platzhalter ohne Wert ist leer, und ein leeres Segment entfällt: Ein HTML- oder URL-Rendering und das Ergebnis eines PDF-Tools haben kein {template}, ein Rendering außerhalb eines Batches kein {job_id} und das Zip eines Batches kein eigenes {render_id} (an seine Stelle tritt die ID des Batches, wenn die Vorlage {job_id} nicht verwendet).

Mit dem Präfix formfeed/ landet ein Rendering der Vorlage invoice im Workspace fennlor am 23. September 2026 mit "filename": "invoice-2026-0042.pdf" hier:

Vorlage für ObjektschlüsselObjektschlüssel
{yyyy}/{mm}/{render_id}/{filename} (Standard)formfeed/2026/09/rnd_7da09ab6cd9946b798c187c2a9350852/invoice-2026-0042.pdf
{template}/{yyyy}-{mm}-{dd}/{render_id}-{filename}formfeed/invoice/2026-09-23/rnd_7da09ab6cd9946b798c187c2a9350852-invoice-2026-0042.pdf
{environment}/{workspace}/{template}/{render_id}/{filename}formfeed/live/fennlor/invoice/rnd_7da09ab6cd9946b798c187c2a9350852/invoice-2026-0042.pdf
Jeder Schlüssel muss eindeutig sein

Ein Objekt, das unter einem Schlüssel schon existiert, wird überschrieben. Behalten Sie deshalb {render_id} oder einen eindeutigen filename in der Vorlage. Das Zip eines Batches hat keine eigene Rendering-ID: Verwendet die Vorlage {job_id} nicht, tritt die ID des Batches an die Stelle von {render_id}, sodass ein Zip mit der Standardvorlage unter formfeed/2026/09/job_…/outputs.zip landet. Um einen Batch zusammenzuhalten, ergänzen Sie {job_id}, zum Beispiel {yyyy}/{mm}/{job_id}/{render_id}/{filename}: Ein einzelnes Rendering landet weiterhin unter formfeed/2026/09/rnd_…/invoice-2026-0042.pdf, ein Eintrag eines Batches unter formfeed/2026/09/job_…/rnd_…/<filename> und das Zip unter formfeed/2026/09/job_…/outputs.zip.

Jedes Objekt trägt seinen Content-Type und diese Metadaten:

HeaderWert
x-amz-meta-formfeed-render-idDie ID des Renderings, rnd_…
x-amz-meta-formfeed-job-idDie ID des Batches, job_…, bei Einträgen eines Batches und beim Zip
x-amz-meta-sha256Der SHA-256 der Datei, derselbe wie output_sha256 des Renderings

Ein ACL-Header wird nicht gesendet, die Richtlinien des Buckets entscheiden also, wer ein Objekt lesen darf, und die serverseitige Verschlüsselung folgt dem Standard des Buckets.

Das Objekt in der Anfrage benennen​

storage in einer Anfrage steuert die Kopie dieses einen Dokuments:

{
"template": "invoice",
"data": { "invoice": { "number": "RE-1001" } },
"filename": "RE-1001.pdf",
"storage": { "key": "invoices/2026/RE-1001.pdf" }
}
storageWirkung
Weggelassen oder nullKopie nach der Vorlage für Objektschlüssel
{ "key": "invoices/2026/RE-1001.pdf" }Kopie genau unter diesem Schlüssel unterhalb des Präfixes, hier formfeed/invoices/2026/RE-1001.pdf
falseDieses Dokument wird nicht kopiert
  • Der Schlüssel ist relativ: kein / am Anfang oder Ende, keine Segmente ., .. oder leere, keine Backslashes oder Steuerzeichen, höchstens 1024 Byte. Er landet immer unterhalb des Präfixes. Ein Schlüssel, der eine Regel verletzt, antwortet mit 400 invalid_request.
  • Ein Objekt, das unter dem Schlüssel schon existiert, wird überschrieben; Formfeed prüft das nicht vorher.
  • Eine Anfrage mit eigenem Schlüssel wird nie aus dem Cache der Deduplizierung beantwortet, jede Anfrage schreibt also ihren Schlüssel.
  • Ohne Verbindung antwortet eine Anfrage mit storage.key mit 409 storage_not_configured, bevor etwas gerendert oder berechnet wird. storage: false wird in jedem Fall angenommen.
  • Eine Anfrage, die die Verbindung nicht übernimmt (ein Test-Rendering, während nur live kopiert wird, oder jedes Rendering, solange der Plan keinen eigenen Speicher enthält), wird angenommen und nicht kopiert, mit oder ohne Schlüssel; ihr storage ist null. Ist die Verbindung nach Fehlschlägen pausiert, wird eine Anfrage ebenfalls angenommen: Ihr storage ist eine fehlgeschlagene Kopie mit dem Grund connection_paused, die eine erneute Zustellung sendet, sobald die Verbindung wieder funktioniert.
  • Die Option funktioniert genauso bei POST /pdf/merge, /pdf/protect, /pdf/watermark und /pdf/convert. Ein Multipart-Upload an /pdf/convert sendet sie als Formularfeld mit JSON-Text: storage={"key":"offers/A-17.pdf"} oder storage=false.
  • In einem Batch gehört storage zu jedem einzelnen Eintrag. Das Zip des Batches, wenn er eines anfordert, wird immer nach der Vorlage für Objektschlüssel kopiert.
const render = await client.renders.create({
template: 'invoice',
data: { invoice: { number: 'RE-1001' } },
storage: { key: 'invoices/2026/RE-1001.pdf' },
});
console.log(render.storage?.status); // 'pending'

Das TypeScript-SDK typisiert die Option als StorageOption und das Feld als Render.storage (StorageDelivery); in Python ist die Option das Schlüsselwortargument storage= und das Feld render.storage.

Was kopiert wird​

  • Die Ausgaben von Vorlagen-, HTML- und URL-Renderings, Word- und PowerPoint-Vorlagen eingeschlossen
  • Das XML einer E-Rechnung, die "xml": "both" angefordert hat, als zweites Objekt neben dem PDF: Nach der Vorlage für Objektschlüssel endet sein {filename} auf .xml, und mit einem storage.key erhält es diesen Schlüssel mit der Endung .xml statt der ursprünglichen
  • Die Ergebnisse der PDF-Tools, /pdf/convert eingeschlossen
  • Jeder Eintrag eines Batches, und das Zip des Batches, wenn der Batch eines angefordert hat
  • Test-Renderings nur, wenn die Umgebungen der Verbindung test enthalten

Nie kopiert werden das HTML, das Formfeed von einem fehlgeschlagenen Rendering aufbewahrt, und Dokumente, die gerendert wurden, während der Plan keinen eigenen Speicher enthielt. Dokumente, die gerendert werden, während die Verbindung nach Fehlschlägen pausiert ist, werden als fehlgeschlagene Kopien (connection_paused) festgehalten, sodass eine erneute Zustellung sie sendet, sobald die Verbindung wieder funktioniert.

Eine deduplizierte Antwort ist das frühere Rendering und trägt dessen storage: Es wird keine zweite Kopie geschrieben, und mit der Standardvorlage liegt das Objekt schon unter der ID des früheren Renderings. Ein früheres Rendering, dessen Kopie fehlgeschlagen ist, das vor der Verbindung entstand oder das storage: false angab, wird nicht wiederverwendet: Die Anfrage rendert erneut, damit Ihr Bucket seine Kopie bekommt. Eine Kopie kostet keine Einheiten.

Das Feld storage​

Jedes Rendering und jeder Job trägt storage, die Kopie in Ihrem Bucket, oder null, wenn keine vorgesehen ist: keine Verbindung, ein Plan ohne eigenen Speicher, storage: false, ein Test-Rendering, das die Verbindung nicht übernimmt, ein fehlgeschlagenes Rendering oder ein Rendering, das noch in der Warteschlange steht. Solange die Verbindung nach Fehlschlägen pausiert ist, ist storage eine fehlgeschlagene Kopie mit dem Grund connection_paused.

"storage": {
"status": "pending",
"bucket": "fennlor-docs",
"key": "formfeed/2026/09/rnd_7da09ab6cd9946b798c187c2a9350852/invoice-2026-0042.pdf",
"url": "https://files.fennlor.example/formfeed/2026/09/rnd_7da09ab6cd9946b798c187c2a9350852/invoice-2026-0042.pdf",
"reason": null,
"error": null
}
FeldBedeutung
statuspending, delivered oder failed
bucket, keyIhr Bucket und der vollständige Objektschlüssel, Präfix eingeschlossen
urlDas Objekt unter der öffentlichen Basis-URL der Verbindung; null ohne eine und bei einer fehlgeschlagenen Kopie
reasonWarum die Kopie fehlgeschlagen ist, siehe die Gründe; sonst null
errorBei einer fehlgeschlagenen Kopie die Antwort des Anbieters, gekürzt auf code, http_status und die erste Zeile von message; sonst null
  • Ein synchrones 200 meldet pending, weil die Kopie erst beginnt, wenn das Rendering gespeichert ist. GET /renders/{id} zeigt, wie weit sie gekommen ist.
  • Das Ereignis render.completed trägt den Stand bei seiner Erzeugung, meist pending. Warten Sie auf storage.delivered, wenn Sie mit der Kopie weiterarbeiten.
  • Bei einem Job ist storage die Kopie des Zips; jeder Eintrag in items trägt seine eigene.

Ereignisse​

Zwei Webhook-Ereignisse melden Kopien. Beide sind Opt-in: Ein Endpunkt erhält sie nur, wenn seine Liste events sie nennt, was kein Endpunkt tut, bis Sie sie ergänzen, bestehende eingeschlossen. formfeed listen leitet sie weiter, wenn --events sie nennt; ohne --live nur die von Test-Renderings, die die Verbindung nur kopiert, wenn ihre Umgebungen test enthalten. Die eigene webhook_url eines Renderings erhält sie nicht. Die Umgebungen des Endpunkts gelten, nach der Umgebung des kopierten Renderings.

EreignisGesendet, wenn
storage.deliveredEine Kopie liegt in Ihrem Bucket
storage.failedEine Kopie ist endgültig fehlgeschlagen, mit reason permanent, rejected oder exhausted

Versuche, die noch wiederholt werden, lösen kein Ereignis aus, ebenso wenig eine Kopie, die aus einem der anderen Gründe endet.

{
"id": "evt_9d4b2f7a1c3e4d5f8a6b0c2e4f6a8b1d",
"type": "storage.delivered",
"created_at": "2026-09-23T10:00:03Z",
"workspace_id": "3a6f…",
"data": {
"id": "dlv_4c1e7a9b2d3f4a5b8c6d0e1f2a3b4c5d",
"status": "delivered",
"bucket": "fennlor-docs",
"key": "formfeed/2026/09/rnd_7da09ab6cd9946b798c187c2a9350852/invoice-2026-0042.pdf",
"url": "https://files.fennlor.example/formfeed/2026/09/rnd_7da09ab6cd9946b798c187c2a9350852/invoice-2026-0042.pdf",
"reason": null,
"error": null,
"render_id": "rnd_7da09ab6cd9946b798c187c2a9350852",
"job_id": null,
"output": "pdf",
"content_type": "application/pdf",
"bytes": 48213,
"sha256": "b41e…",
"etag": "\"5d41402abc4b2a76b9719d911017c592\"",
"attempts": 1,
"created_at": "2026-09-23T10:00:01Z",
"delivered_at": "2026-09-23T10:00:02Z"
}
}

Ein Ereignis storage.failed hat dieselbe Form, mit status: "failed", einem reason, dem error des Anbieters und delivered_at: null:

"data": {
"id": "dlv_7b2e9c4a1d6f4e8a9c0b3d5f7a2c4e6b",
"status": "failed",
"reason": "permanent",
"error": { "code": "AccessDenied", "http_status": 403, "message": "Access Denied" },
"attempts": 1,
"delivered_at": null,
…
}

data enthält die Felder aus dem Feld storage und:

FeldBedeutung
idDie Kopie, dlv_…
render_id, job_idDas Rendering und, bei Einträgen eines Batches und Zips, der Batch; das Zip hat keine render_id
outputpdf, png, jpg, webp, docx, pptx, zip oder xml für das XML einer E-Rechnung
content_type, bytes, sha256Typ, Größe und SHA-256 der Datei
etagDas ETag, mit dem Ihr Bucket geantwortet hat
attemptsBisherige Versuche
created_at, delivered_atWann die Kopie eingereiht wurde und wann sie ankam

Die SDKs typisieren data als StorageDeliveryEvent:

import { parseWebhookEvent, type StorageDeliveryEvent } from '@formfeed/sdk';

const event = await parseWebhookEvent(
process.env.FORMFEED_WEBHOOK_SECRET!,
req.get('webhook-signature'),
req.body,
);
if (event.type === 'storage.failed') {
const copy = event.data as StorageDeliveryEvent;
log.warn(
{ key: copy.key, reason: copy.reason, error: copy.error },
'copy to our bucket failed',
);
}

Wiederholungen und Pausieren​

Jede Kopie wird direkt nach dem Rendering gesendet. Was passiert, wenn Ihr Bucket sie ablehnt, hängt von der Antwort ab:

Ihr Bucket antwortetWas passiert
Keine Antwort: eine Zeitüberschreitung (TimeoutError, RequestTimeout), ein Verbindungsfehler oder ein Hostname, der sich gerade nicht auflösen lässt (NetworkError)Neuer Versuch nach 1 Minute, 5 Minuten, 30 Minuten, 2 Stunden und 12 Stunden, nach demselben Zeitplan wie bei Webhooks. Scheitert auch der sechste Versuch, schlägt die Kopie mit dem Grund exhausted fehl
Ein Serverfehler (5xx, InternalError, ServiceUnavailable), Drosselung (SlowDown, Throttling, TooManyRequests, 429), eine abweichende Uhr (RequestTimeTooSkewed) oder eine vorübergehende Weiterleitung (307, TemporaryRedirect)Ebenso wiederholt
AccessDenied, InvalidAccessKeyId, SignatureDoesNotMatch, NoSuchBucket, InvalidBucketName, AllAccessDisabled, AccountProblem, abgelaufene oder ungültige Zugangsdaten (ExpiredToken, InvalidToken), eine falsche Region (PermanentRedirect, AuthorizationHeaderMalformed, 301), ein Endpunkt, den Formfeed nicht erreichen darf (EndpointBlocked: eine private oder interne Adresse), oder ein anderes 401, 403 oder 404Schlägt sofort mit dem Grund permanent fehl, weil eine Wiederholung nicht helfen kann, und zählt für das Pausieren
Ein anderes 4xx für dieses eine Objekt, etwa EntityTooLargeSchlägt sofort mit dem Grund rejected fehl, ohne für das Pausieren zu zählen

Eine Kopie, die mit exhausted fehlschlägt, zählt ebenfalls nicht für das Pausieren.

Pausieren. Nach fünf dauerhaften Fehlschlägen in Folge pausiert Formfeed die Verbindung (status: "paused", paused_reason: "failures") und sendet den Inhabern und Admins der Organisation eine E-Mail. Eine gelungene Kopie dazwischen setzt die Zählung zurück. Renderings laufen weiter wie gewohnt, solange die Verbindung pausiert ist, aber nichts wird kopiert: Noch wartende Kopien und die Kopien der Dokumente, die während der Pause gerendert werden, werden als fehlgeschlagen mit dem Grund connection_paused festgehalten.

Zum Fortsetzen korrigieren Sie den Schlüssel oder den Bucket und drücken dann Erneut testen auf der Karte Eigener Speicher (oder rufen POST /workspaces/{id}/storage/test auf), was mit dem gespeicherten Schlüssel testet. Ein bestandener Test setzt sie fort; ein fehlgeschlagener aktualisiert nur den zuletzt angezeigten Fehler. Besteht die Korrektur in einem neuen Schlüssel, geben Sie ihn ein und drücken Testen und speichern: Ein bestandener Test speichert ihn und setzt die Verbindung ebenfalls fort. Stellen Sie danach erneut zu, was fehlgeschlagen ist, einschließlich aller Dokumente, die gerendert wurden, während die Verbindung pausiert war.

Gründe​

reasonBedeutungEreignis
permanentZugangsdaten, Bucket oder Endpunkt sind falschstorage.failed
rejectedIhr Bucket hat dieses eine Objekt abgelehntstorage.failed
exhaustedAlle Wiederholungen sind gescheitertstorage.failed
source_goneDie Kopie bei Formfeed war abgelaufen oder gelöscht, bevor sie gesendet werden konntekeines
connection_deletedDie Verbindung wurde gelöscht, während die Kopie wartetekeines
connection_pausedDie Verbindung wurde pausiert, während die Kopie wartete, oder war es, als das Dokument gerendert wurdekeines
planDer Plan enthält keinen eigenen Speicher mehrkeines
workspace_deletedDer Workspace wurde gelöschtkeines

Erneute Zustellung​

POST /v1/workspaces/{id}/storage/redeliver sendet fehlgeschlagene Kopien erneut, aus den Dokumenten, die Formfeed noch aufbewahrt, ohne etwas neu zu rendern. In der App erledigt Fehlgeschlagene Zustellungen wiederholen auf der Karte Eigener Speicher das für jede fehlgeschlagene Kopie des Workspace. Der Body wählt aus:

BodySendet erneut
{ "render_id": "rnd_…" }Die fehlgeschlagene Kopie eines Renderings
{ "job_id": "job_…" }Die fehlgeschlagenen Kopien eines Batches, sein Zip eingeschlossen
{ "since": "2026-09-22T00:00:00Z", "until": "2026-09-23T00:00:00Z" }Die fehlgeschlagenen Kopien, die in diesem Zeitraum eingereiht wurden, also als ihre Dokumente fertig wurden; jede der beiden Grenzen darf fehlen. ISO 8601 mit Zeitzone
{} oder kein BodyJede fehlgeschlagene Kopie des Workspace

Die Antwort ist 202 mit der Zahl der eingereihten Kopien: { "requeued": 42, "truncated": false }. Ein Aufruf reiht höchstens 10.000 ein; bei truncated: true rufen Sie erneut auf. Jede Kopie geht an den aktuellen Bucket und Schlüssel der Verbindung (ihren eigenen storage.key oder die aktuelle Vorlage für Objektschlüssel, unterhalb des aktuellen Präfixes), beginnt wieder beim ersten Versuch und folgt den üblichen Wiederholungen.

Erneut senden lassen sich nur Dokumente, die Formfeed noch aufbewahrt: Eine Kopie, deren Ausgabe über die Aufbewahrungsfrist des Workspace hinaus ist oder vorzeitig gelöscht wurde, wird übersprungen, ebenso eine, die mit source_gone fehlgeschlagen ist. Solange die Verbindung pausiert ist, nach Fehlschlägen oder durch den Plan, antwortet der Aufruf mit 409 conflict und paused_reason: Testen Sie sie zuerst, oder wechseln Sie zurück in einen Plan mit eigenem Speicher. Ein Workspace ohne Verbindung antwortet mit 409 storage_not_configured.

Was Formfeed nie tut​

  • Lesen, listen oder löschen in Ihrem Bucket. Die einzige Anfrage, die es sendet, ist PutObject, auch beim Verbindungstest.
  • Ihre Kopien entfernen. Das Löschen der Ausgaben eines Renderings, ihr Ablauf am Ende der Aufbewahrungsfrist des Workspace und das Löschen eines Workspace oder der Organisation lassen jedes Objekt in Ihrem Bucket, wo es ist. Löschen Sie Objekte selbst, wenn Ihre eigenen Aufbewahrungsregeln es verlangen.
  • Vor dem Überschreiben prüfen. Ein Objekt unter demselben Schlüssel wird ersetzt.
  • Den geheimen Zugriffsschlüssel wieder anzeigen. In App und API erscheinen nur die letzten vier Zeichen der Zugriffsschlüssel-ID. Das Löschen eines Workspace oder der Organisation vernichtet die gespeicherten Zugangsdaten sofort, nicht erst nach den 30 Tagen, in denen die übrigen Daten des Workspace entfernt werden.

Wohin die Dokumente gehen​

Die Kopie erstellt der Render-Worker der Region, die das Dokument gerendert hat; für die EU-Region ist das Frankfurt. Von dort verlässt das Dokument den Speicher von Formfeed in der EU in Richtung Ihres Buckets, wo auch immer dieser liegt: Das Ziel wählen Sie. Ihr eigener Bucket ist kein Unterauftragsverarbeiter von Formfeed, die Liste der Unterauftragsverarbeiter nennt ihn deshalb nicht. Um Dokumente in der EU zu halten, wählen Sie einen Bucket in einer EU-Region oder bei R2 den Rechtsraum eu. Siehe Datenresidenz.

API-Routen​

Die App verwaltet eine Verbindung über diese Routen. In dieser Version nehmen sie nur die Sitzung eines Inhabers oder Admins an, der in der App angemeldet ist: Kein Scope eines API-Schlüssels umfasst den Speicher, ein API-Schlüssel oder ein per OAuth verbundener KI-Agent erhält deshalb 403 forbidden.

RouteWirkung
GET /workspaces/{id}/storageLiest die Verbindung ohne ihren geheimen Zugriffsschlüssel, mit access_key_hint (den letzten vier Zeichen der Zugriffsschlüssel-ID), status, paused_reason, last_error und den Kopien der letzten 24 Stunden nach Status in deliveries_24h
PUT /workspaces/{id}/storageTestet und legt dann die Verbindung an (201) oder ersetzt sie (200). Ein fehlgeschlagener Test antwortet mit 422 storage_test_failed, mit dem error des Anbieters und dem key des Testobjekts
DELETE /workspaces/{id}/storageEntfernt die Verbindung (204)
POST /workspaces/{id}/storage/testTestet die gespeicherte Verbindung erneut; ein bestandener Test setzt eine pausierte fort und antwortet mit 200, mit ok, dem key des Testobjekts und der connection. Ein Fehlschlag antwortet mit 422 storage_test_failed, ein Workspace ohne Verbindung mit 409 storage_not_configured
POST /workspaces/{id}/storage/redeliverErneute Zustellung

{id} ist die ID des Workspace. Ein Workspace einer anderen Organisation antwortet mit 404 not_found, ebenso GET und DELETE, wenn der Workspace keine Verbindung hat.