From data to sealed PDF in one API call
The question we heard most often from customers with a working automation stack was short: “Why do I have to render my invoice somewhere else first before I can hand it to you?” A fair question. An n8n or Make flow that wants to turn a database row or a webhook payload into a clean compliance PDF used to stall on exactly that step. You had data. You wanted a sealed PDF/A-3. Between those two sat a third-party tool with its own price tag, its own rate limit, and its own template system that fought our intake validator at every regulatory change.
That intermediary is gone. Since Sprint 49 and 50 you can post Markdown or a JSON invoice straight to SealDoc and get back a PDF/A-3 that meets the same rules as everything that flows through our custody pipeline: archive format, embedded XML where applicable, optional RFC 3161 timestamp, optional evidence pack, idempotency-key support. One HTTP request, one response, ready for your retention bucket.
What is new
Two new endpoints and one hardening change in the evidence packs.
POST /api/documents/generate
Send Markdown or HTML, get a PDF/A-3 back. The HTML sanitizer (see ADR-0013) strips scripts, iframes, external images and CSS url() references server-side, because a document you archive must not be able to silently change its content later through an external asset. Maximum 100KB content per request: large enough for a typical compliance report, small enough to discourage abuse. Idempotency-Key is supported, which means a retry from your n8n flow does not produce a second PDF in your archive.
curl -X POST https://api.sealdoc.eu/api/documents/generate \
-H "X-Api-Key: $KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: report-2026-q1-acme-001" \
-d '{
"format": "markdown",
"content": "# Q1 2026 Compliance Report\n\nTenant: Acme BV\n\nAll 142 invoices processed without exceptions.",
"title": "Q1 2026 Compliance Report",
"timestampRfc3161": true,
"generateEvidencePack": true
}'
POST /api/invoices/generate
Same principle, but for invoices. You send a structured JSON payload (seller, buyer, lines, totals, dates) and you get back a Factur-X 1.0 BASIC PDF/A-3 with the CII XML already embedded. No intermediate step where you first have to render a visual PDF that we then need to match semantically.
Two things you should know before you ship this to production.
VAT-leading seller validation. The VAT number in seller.vatNumber must match the VAT number on your own tenant. We canonicalise both sides (uppercase, dots and spaces stripped) before comparing, so nl 1234.56789.b01 matches NL123456789B01 just fine. If they do not match, you get a 403 with a clear error code. This is not bureaucracy: it stops one tenant from issuing invoices in another tenant’s name. CompanyName drift between request and tenant is not blocked (trade names change), but it is logged as an audit warning so it stays traceable.
Billing profile must be complete. VAT, Address, PostalCode, City and Country on your tenant together form the seller identity on the invoice. If one is missing you get a 412 with the list of missing fields. You fix that once in the portal and never again.
curl -X POST https://api.sealdoc.eu/api/invoices/generate \
-H "X-Api-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{
"seller": {"name":"Acme BV","vatNumber":"NL123456789B01","address":"Singel 1","postalCode":"1011AB","city":"Amsterdam","country":"NL"},
"buyer": {"name":"Klant BV","vatNumber":"BE0123456789","address":"Avenue 5","postalCode":"1000","city":"Brussels","country":"BE"},
"invoiceNumber":"2026-001","invoiceDate":"2026-05-07","currency":"EUR","vatRate":0.21,"vatLabel":"21% BTW",
"lines":[{"description":"Consulting","quantity":10,"unitPriceExclVat":150,"unit":"hour"}],
"timestampRfc3161":true,"generateEvidencePack":true
}'
For recurring billing the rule is simple: always send an explicit Idempotency-Key. We do not auto-deduplicate on payload hash, because two identical monthly invoices within 24 hours can be entirely legitimate (a correction, a re-issue, a double subscription on one legal entity). Idempotency-Key in your flow’s hands, no-duplicates guarantee in ours.
Manifest hash-chain v2.0 in evidence packs
A less visible but, for auditors, important hardening step. Every evidence pack now contains a MANIFEST.json with per-file SHA256 and byte size, alphabetically sorted by name, in canonical JSON (camelCase, no indentation). Next to it sits a manifest.sha256: the SHA256 of those canonical manifest bytes.
Why two levels. Per-file hashes detect content tampering of any one file. The manifest hash detects tampering of the file set itself: silently dropping an attachment from the ZIP and rewriting the manifest accordingly. The latter was still possible under v1.0; under v2.0 it is no longer possible without a verifier noticing.
Important: this is not a cryptographic signature, and that is why it is not called manifest.signature. The naming reflects what it is, a hash-chain. A future sprint may add Cosign or a SealDoc-owned key for non-repudiation; at that point manifest.signature becomes the right name, and not before.
A real example: Stripe webhook to archived Factur-X
Concretely, here is how the three changes together unlock a class of workflows that did not exist before.
Say you sell SaaS subscriptions through Stripe. On every successful invoice.payment_succeeded webhook you want a Dutch Factur-X invoice in your own archive, separate from whatever Stripe generates (Stripe’s PDF is not PDF/A-3, not Factur-X, and has no RFC 3161 timestamp).
The flow in n8n or Make:
- Stripe Trigger catches
invoice.payment_succeeded. - Function node maps the Stripe object to a SealDoc invoice payload.
customer_addressbecomesbuyer,linesis built fromlines.data, your own VAT number sits statically in the seller block. - HTTP Request node does a
POST /api/invoices/generatewithIdempotency-Key: stripe-{{$json.id}}. Stripe might happily redeliver the same webhook three times; your archive gets exactly one invoice. - HTTP Response node receives a PDF/A-3 stream plus an evidence pack URL. Write the PDF to your retention bucket, store the evidence pack URL in your accounting database.
That is it. No separate rendering step, no template engine you maintain, no XML you hand-craft. The Factur-X XML comes out of our side of the wall, conformant to 1.0 BASIC, and fits in any PEPPOL-pipeline a recipient can ask for.
What it solves
The three changes together address one problem, and that is the gap between “I have structured data” and “I have a document that a Belgian or French tax authority accepts as the original”. Before Sprint 49 that gap was a third party. Since Sprint 50 it is an HTTP request.
If your flow already runs through n8n-nodes-sealdoc or the Make app, the new endpoints land in the next node release. If you talk directly to the REST API, they are live today.
And while we were at it: yearly billing
A smaller change, but enough customers asked for it that it deserves a mention. In the subscription settings you can now toggle between Monthly and Yearly. Yearly pricing is PriceMonthly × 10, which is two months free, and Mollie charges every twelve months instead of every thirty days. No migration, no contract change: flip the toggle on your next renewal moment.
The compliance plumbing is solved. What you do with the time you save back is up to you.