Stripe
Every invoice Stripe finalises can become a ZUGFeRD / Factur-X e-invoice: the same number, lines, VAT and totals, as a PDF/A-3 with the invoice as XML inside, validated before you receive it. There is nothing to install in Stripe. A webhook handler and the function on this page are the whole integration: the function turns Stripe's invoice into the _invoice block, and one render makes the file.
Before you start
- A template that prints the
_invoiceblock. Templates → Examples → E-invoice in the app puts a copy of one into your workspace, with the sluge-rechnungthat the handler below renders, unless you give it another one there. A Stripe invoice needs a few changes to it; The template shows them. - A Formfeed API key. The default scopes are enough. Start with a
ff_test_key: its e-invoices are valid files with the test mark on every page, up to a number a day that depends on the plan. Live e-invoices need the Starter plan or higher. - Stripe's API version 2025-03-31 or later, which every
stripepackage from version 18 uses: the mapping reads thetaxesof a line item, which came with that version. A restricted key needs write access to invoices (the handler reads them, lists their lines and notes the render in their metadata) and read access to tax rates.
The mapping
stripeToInvoice takes the invoice as Stripe's API returns it and all its line items, listed with their tax rates expanded, and returns the _invoice block. It copies Stripe's amounts and computes nothing it could get wrong:
- One line per line item. The name is the line's description, the quantity and price are Stripe's, the net amount is what the VAT is calculated on. A discount or credit grant stays visible as an allowance on its line. A line whose price includes VAT is written at its net, with the discount inside the price. A credit, such as the unused time of a subscription after an upgrade, is a negative quantity at a positive price, as EN 16931 wants it.
- VAT from Stripe's own reason. A line taxed at the standard or a reduced rate is category
Swith the rate Stripe applied. Every other reason (reverse charge, exempt, not subject to tax, not collecting) and a line without any tax are categories you state in the configuration. An unknown reason throws rather than guessing. - Totals from the lines, held against Stripe's. When the lines do not come to Stripe's total, the function throws. What is paid already, or settled from the customer's credit balance, is
prepaid, and Stripe's amount remaining isdue. - The buyer as Stripe froze it. Name, address, EU VAT ID and e-mail are the invoice's
customer_*fields, which stop changing when the invoice is finalised. The e-mail becomes the buyer's electronic address. - Dates in your time zone. The issue date is the invoice's date of issue (
effective_at), the periods of the lines are their service periods. Asend_invoiceinvoice is due on its due date; one that Stripe charges automatically is due the day it is issued, because Stripe collects it then.
// Stripe's taxability reasons that are one VAT category of EN 16931,
// whatever was sold. Every other reason depends on the sale: state it in
// config.categories.
const STANDARD_RATED = ['standard_rated', 'reduced_rated'];
// Currencies whose amounts Stripe states in whole units, not in cents.
const ZERO_DECIMAL =
'bif clp djf gnf jpy kmf krw mga pyg rwf ugx vnd vuv xaf xof xpf'.split(' ');
/**
* A finalised Stripe invoice as the `_invoice` block of a Formfeed e-invoice.
*
* `invoice` is the invoice as Stripe's API returns it; `lines` are all its
* line items, listed with `expand[]=data.taxes.tax_rate_details.tax_rate`.
* Every amount is copied from Stripe, never worked out from a price: a total
* that differs from Stripe's is an error, not a rounding.
*/
export function stripeToInvoice(invoice, lines, config) {
const where = `Invoice ${invoice.number ?? invoice.id}`;
const fail = (message) => {
throw new Error(`${where}: ${message}`);
};
if (!invoice.number) fail('a draft has no number; finalise it first');
if (invoice.amount_shipping) fail('shipping costs are not mapped');
if (ZERO_DECIMAL.includes(invoice.currency)) fail('a currency without cents');
const money = (cents) => (cents / 100).toFixed(2);
const sum = (list) =>
(list ?? []).reduce((all, item) => all + item.amount, 0);
const day = (seconds) => {
const parts = new Intl.DateTimeFormat('en', {
timeZone: config.timeZone ?? 'UTC',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date(seconds * 1000));
const [y, m, d] = ['year', 'month', 'day'].map(
(type) => parts.find((part) => part.type === type).value,
);
return `${y}-${m}-${d}`;
};
// Stripe writes null where a value is missing; the block leaves it out
const present = (object) =>
Object.fromEntries(
Object.entries(object).filter(
([, value]) => value != null && value !== '',
),
);
const breakdown = new Map(); // one entry per VAT category and rate
let lineNet = 0;
let taxTotal = 0;
const invoiceLines = lines.map((line, index) => {
const at = `line ${index + 1}`;
const taxes = line.taxes ?? [];
if (taxes.length > 1) fail(`${at} carries ${taxes.length} taxes`);
const tax = taxes[0];
const vat = vatOf(tax, config) ?? fail(`${at}: ${unknownVat(tax)}`);
const inclusive = tax?.tax_behavior === 'inclusive';
// discounts and credit grants; the first list holds both
const credits =
sum(line.pretax_credit_amounts) || sum(line.discount_amounts);
const taxAmount = tax?.amount ?? 0;
// what the VAT is calculated on: the amount after discounts, without VAT
const whole = line.amount - credits - (inclusive ? taxAmount : 0);
const net = tax?.taxable_amount ?? whole;
if (net !== whole) fail(`${at} is taxed on part of its amount`);
// a price that includes VAT is written at its net, discount included;
// any other line keeps Stripe's amount and shows the discount
const amount = inclusive ? net : line.amount;
const quantity = Number(line.quantity_decimal ?? line.quantity ?? 1);
const price = quantity ? Math.abs(amount) / 100 / quantity : 0;
lineNet += net;
taxTotal += taxAmount;
const key = `${vat.category} ${vat.rate}`;
const entry = breakdown.get(key) ?? { vat, basis: 0, amount: 0 };
entry.basis += net;
entry.amount += taxAmount;
breakdown.set(key, entry);
const reason = config.discountReason ?? 'Discount';
return present({
id: String(index + 1),
name: line.description || 'Item',
// a credit (unused time after an upgrade) is a negative quantity
quantity: amount < 0 ? -quantity : quantity,
unit_code: 'C62',
net_price: String(Number(price.toFixed(6))),
net_amount: money(net),
allowances:
amount === net ? undefined : [{ amount: money(amount - net), reason }],
tax: present({ category: vat.category, rate: vat.rate }),
period: { start: day(line.period.start), end: day(line.period.end) },
});
});
const grand = lineNet + taxTotal;
if (grand !== invoice.total)
fail(`the lines come to ${money(grand)}, not ${money(invoice.total)}`);
// paid already, or settled from the customer's credit balance
const prepaid = invoice.total - invoice.amount_remaining;
if (prepaid < 0) fail('it also collects a balance owed from before');
// an invoice that is not subject to VAT names no VAT ID (BR-O-02)
const outOfScope = [...breakdown.values()].some(
({ vat }) => vat.category === 'O',
);
const vatId = (id) => (outOfScope ? undefined : id);
const address = invoice.customer_address ?? {};
const issued =
invoice.effective_at ?? invoice.status_transitions.finalized_at;
const transfer =
invoice.collection_method === 'send_invoice' ? config.transfer : null;
const notes = [invoice.description, invoice.footer]
.filter(Boolean)
.map((text) => ({ text }));
return present({
number: invoice.number,
issue_date: day(issued),
currency: invoice.currency.toUpperCase(),
notes: notes.length > 0 ? notes : undefined,
seller: present({ ...config.seller, vat_id: vatId(config.seller.vat_id) }),
buyer: present({
name: invoice.customer_name,
vat_id: vatId(
invoice.customer_tax_ids?.find((id) => id.type === 'eu_vat')?.value,
),
address: present({
street: address.line1,
street2: address.line2,
postcode: address.postal_code,
city: address.city,
subdivision: address.state,
country: address.country,
}),
electronic_address: invoice.customer_email,
}),
lines: invoiceLines,
tax: {
breakdown: [...breakdown.values()].map(({ vat, basis, amount }) =>
present({ ...vat, basis: money(basis), amount: money(amount) }),
),
},
totals: present({
line_net: money(lineNet),
tax_basis: money(lineNet),
tax_total: money(taxTotal),
grand: money(grand),
prepaid: prepaid > 0 ? money(prepaid) : undefined,
due: money(invoice.amount_remaining),
}),
payment: present({
// Stripe charges an automatic invoice as it is finalised: due that day
due_date: day(invoice.due_date ?? issued),
means_code: transfer ? '58' : undefined,
iban: transfer?.iban,
bic: transfer?.bic,
reference: transfer ? invoice.number : undefined,
terms: config.terms?.[invoice.collection_method],
}),
});
}
/** A line's VAT category and rate, from Stripe's reason for its tax. */
function vatOf(tax, config) {
const rateOf = (category, percent) =>
category === 'S' ? percent : category === 'O' ? undefined : 0;
if (!tax)
return (
config.untaxed && {
...config.untaxed,
rate: rateOf(config.untaxed.category, 0),
}
);
const rate = tax.tax_rate_details?.tax_rate;
if (typeof rate !== 'object' || rate === null) return null;
const reason = tax.taxability_reason ?? 'not_available';
const percent = rate.effective_percentage ?? rate.percentage;
const stated = config.categories?.[rate.id] ?? config.categories?.[reason];
if (stated) return { ...stated, rate: rateOf(stated.category, percent) };
const standard =
STANDARD_RATED.includes(reason) ||
(reason === 'not_available' && percent > 0);
return standard ? { category: 'S', rate: percent } : null;
}
/** Why `vatOf` found no category, as the error says it. */
function unknownVat(tax) {
const rate = tax?.tax_rate_details?.tax_rate;
if (!tax) return 'no tax; state its VAT category in config.untaxed';
if (!rate || typeof rate !== 'object')
return 'expand[]=data.taxes.tax_rate_details.tax_rate is missing';
const reason = tax.taxability_reason ?? 'not_available';
return `state the VAT category of ${reason} (tax rate ${rate.id}) in config.categories`;
}
The configuration
What Stripe does not know about an invoice comes from your configuration: who you are, and which VAT category the taxes Stripe applies are.
export const config = {
// the time zone your invoices are dated in
timeZone: 'Europe/Berlin',
// you, with the fields of `seller` in the _invoice block
seller: {
name: 'Fennlor Studio GmbH',
vat_id: 'DE000000000',
legal_registration: 'HRB 00000',
// register court and the like, which the example's footer prints
legal_info: 'Amtsgericht Musterstadt, HRB 00000',
address: {
street: 'Musterstraße 1',
postcode: '12345',
city: 'Musterstadt',
country: 'DE',
},
contact: { email: 'rechnung@fennlor.example' },
electronic_address: 'rechnung@fennlor.example',
},
// Stripe's taxability reasons, or ids of your tax rates, as VAT categories.
// Which category a sale falls under is a tax question: settle it with your
// tax adviser before you add a line.
categories: {
// reverse_charge: { category: 'AE', exemption_reason_code: 'VATEX-EU-AE' },
},
// the account a customer pays a send_invoice invoice into
transfer: { iban: 'DE36 0000 0000 0000 0000 00' },
};
sellertakes every field of the block's seller. An invoice that is not subject to VAT (categoryO) must not name a VAT ID, so the function leavesvat_idout of such an invoice;legal_registrationthen still identifies you, as EN 16931 asks.legal_infois the further legal information an invoice states, which the example prints in its footer.categoriesmaps a Stripe taxability reason or the id of a tax rate (txr_…, which wins over the reason) to a category of the VAT categories table and, for every category butSandZ, itsexemption_reasonorexemption_reason_code. Stripe'sreverse_chargecovers what EN 16931 tells apart asAE(reverse charge) andK(intra-community supply); which one a sale is, and what the invoice gives as the reason, is for you and your tax adviser to decide. A manual tax rate without a reason and above 0 % counts as categoryS.untaxedis the category of a line that carries no tax at all, for example when Stripe calculates no tax for your account:{ category: 'E', exemption_reason: '…' }with the reason your invoices state. Without it, such a line throws.transferis the account of asend_invoiceinvoice: the invoice then says SEPA credit transfer (58) to that IBAN with the invoice number as reference. Leave it out when your customers pay on Stripe's hosted page.terms, optional, is a payment term per collection method, such as{ charge_automatically: 'Collected from the card on file.' }.discountReasonnames the allowance a discount becomes (Discountby default).
The webhook handler
Stripe sends invoice.finalized once an invoice has its number. The handler answers at once, reads the invoice and its lines again, and renders the e-invoice:
import { writeFile } from 'node:fs/promises';
import express from 'express';
import Stripe from 'stripe';
import { Formfeed } from '@formfeed/sdk';
import { stripeToInvoice } from './stripe-to-invoice.js';
import { config } from './config.js';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const formfeed = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY });
const app = express();
app.post(
'/stripe/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET,
);
} catch {
return res.sendStatus(400);
}
// Stripe wants a quick answer; the e-invoice is made after it
res.sendStatus(200);
if (event.type !== 'invoice.finalized') return;
const id = event.data.object.id;
makeEinvoice(id).catch((error) => {
// a refused invoice names every field and rule it breaks
console.error(`No e-invoice for ${id}:`, error.problem?.errors ?? error);
});
},
);
async function makeEinvoice(id) {
// read the invoice again: the event's copy is as old as the event, and
// Stripe delivers an event more than once
const invoice = await stripe.invoices.retrieve(id);
if (invoice.metadata.formfeed_render) return;
const lines = [];
for await (const line of stripe.invoices.listLineItems(id, {
limit: 100,
expand: ['data.taxes.tax_rate_details.tax_rate'],
}))
lines.push(line);
let render = await formfeed.renders.create(
{
template: 'e-rechnung',
data: { _invoice: stripeToInvoice(invoice, lines, config) },
post: { einvoice: {} },
filename: `${invoice.number}.pdf`,
meta: { stripe_invoice: id },
},
{ idempotencyKey: `stripe-${id}` },
);
// a render that had to wait goes on in the background: the API answers
// 202, and waitFor follows it until it is done
if (render.status !== 'succeeded')
render = await formfeed.renders.waitFor(render.id);
if (render.status === 'failed')
throw new Error(`${render.id} failed: ${render.error?.detail}`);
await stripe.invoices.update(id, {
metadata: { formfeed_render: render.id },
});
// the file is yours from here: archive it, send it to the customer
await writeFile(
`${invoice.number}.pdf`,
await formfeed.renders.download(render),
);
}
app.listen(3000);
- The answer comes first. Stripe asks for a quick
2xxand retries an event for up to three days when it gets none. An e-invoice takes a few seconds, because the file is converted and validated before it is returned, so the handler answers before it renders. In production, put the id on a queue instead of callingmakeEinvoicedirectly: a failure after the answer is not retried by Stripe. - Twice is once. The idempotency key returns the first answer again for 24 hours, and the render id in the invoice's metadata stops a delivery that comes later. Without both, an event delivered twice makes two e-invoices with one number. Neither catches two deliveries that arrive while the first render is still running, because the key holds an answer only once there is one; a queue that handles one invoice at a time closes that gap. The key also returns a refusal again: after correcting what the API refused, render with a new key, such as
stripe-${id}-2. - A render can come back unfinished. When it has to wait for a free render slot, the API answers
202and finishes it in the background (the automatic fallback).waitForthen polls the render until it is done, for up to two minutes by default, and the handler throws when it failed, so the invoice's metadata only ever names a finished e-invoice. templateis the slug of your copy of the example;post.einvoicemakes any template that prints the block an e-invoice, also one that does not declare it itself. See the options for the profile and for storing the XML as a file of its own.- The region.
new Formfeed({ apiKey })talks to the EU region, the only one that renders today (data residency). - Keep the file. Formfeed deletes outputs when the workspace's retention ends, long before invoices may be destroyed. Archive the delivered file and, beside it, the render's
einvoicereport: see archiving.
When the block breaks a rule of EN 16931, the render is refused before anything is rendered or charged, and einvoice_data_invalid lists every field with its rule. With the mapping above that means data missing in Stripe, most often a customer without an address or a country.
The template
The example prints the lines, the VAT of each rate, the total and a payment paragraph with the account to pay into. A Stripe invoice differs from the example's data in a few ways, and the template has to follow:
- No delivery date. Stripe states a service period for each line instead, which the mapping writes as the line's
period. The example's Leistungsdatum row (date of supply) prints_invoice.delivery.date, and thedatehelper prints the current date for a value that is missing: take that row out, or print it only{% if _invoice.delivery %}. - Line periods, discounts and an amount already paid, which the example does not print.
- No account on an invoice Stripe charges automatically: show the payment paragraph only when the invoice names one.
- More than one VAT rate. The example prints the VAT of each rate but not the VAT total, which the display check looks for.
Print them from the same block, inside the example's loop over the lines and under its totals. The example is a PDF/UA-1 file too (pdf.ua in its settings), so the rows keep their header cells (th scope="row"), as its own rows do, and no cell of a table with header cells may stay empty: a strict check fails the render with pdfua_validation_failed. See Accessible PDFs.
<td>
{{ line.name }} {% if line.period %}
<div class="muted">
{{ line.period.start | date('PP') }} – {{ line.period.end | date('PP') }}
</div>
{% endif %} {% for allowance in line.allowances %}
<div class="muted">
{{ allowance.reason }} −{{ allowance.amount | money }}
</div>
{% endfor %}
</td>
{% if _invoice.tax.breakdown | length > 1 %}
<tr>
<th scope="row">VAT total</th>
<td class="num">{{ _invoice.totals.tax_total | money }}</td>
</tr>
{% endif %} {% if _invoice.totals.prepaid %}
<tr>
<th scope="row">Paid</th>
<td class="num">{{ _invoice.totals.prepaid | money }}</td>
</tr>
<tr>
<th scope="row">Amount due</th>
<td class="num">{{ _invoice.totals.due | money }}</td>
</tr>
{% endif %} {% if _invoice.payment.iban %}
<p class="pay">…</p>
{% endif %}
The display check looks in the text of the PDF for the invoice number, the net total, the VAT total, the total, the amount due where it differs from the total, and the VAT of each rate where there is more than one. With these rows a copy of the example shows all of them. A value it does not find is reported in the render's einvoice.display.missing, and the render still succeeds unless the template sets display_check: "strict".
In n8n
The function needs nothing but JavaScript, so n8n runs it too:
-
A Stripe Trigger node for the event
invoice.finalized. -
An HTTP Request node with your Stripe credential,
GET https://api.stripe.com/v1/invoices/{{ $json.data.object.id }}/lines?limit=100&expand[]=data.taxes.tax_rate_details.tax_rate, named Stripe lines. -
A Code node with this code under the function and the configuration, both pasted without the word
export:Code nodeconst invoice = $('Stripe Trigger').first().json.data.object;const lines = $('Stripe lines').first().json.data;return [{ json: { _invoice: stripeToInvoice(invoice, lines, config) } }]; -
The Formfeed node: Render → Create with your copy of the example, which declares the e-invoice itself, and
{{ JSON.stringify($json) }}as Data (JSON).
An invoice with more than 100 lines needs the HTTP Request node's pagination. The workflow has no metadata check, so let n8n's own execution history tell you whether an invoice was made already.
What the mapping does not cover
- Credit notes. Stripe keeps them as objects of their own. An e-invoice credit note is document type
381with the invoice it corrects inpreceding_invoices; the function maps invoices only. - Shipping costs, a balance from earlier invoices collected with this one, a line with more than one tax or taxed on part of its amount, and currencies without cents throw an error that names the invoice, rather than making an e-invoice that differs from Stripe's.
- Stripe's own invoice e-mail. When Stripe also sends its PDF of the invoice, your customer receives two documents with one number. Which one is the invoice you send is yours to decide, and Stripe's customer e-mail settings say whether it sends its own.
- XRechnung is not offered; Formfeed makes ZUGFeRD / Factur-X in the profiles
en16931andbasic. See what Formfeed does not do. - The VAT treatment. The function copies Stripe's taxes and writes the categories your configuration names. You issue the invoice and answer for its content; Formfeed answers for the format.