Stripe
Prerequisites
- A PolyDoc account and API key - sign up for free, no credit card required. The free plan includes 150 PDF conversions per month.
- A PolyDoc invoice template - note its short ID for the
sourcefield (e.g.[template:jlE-whg]). - A Stripe account with Invoicing enabled - use test mode while you build this flow.
Use Case 1: Compliant E-Invoice on Invoice Finalization
Turn a finalized Stripe invoice into a compliant e-invoice - a single PDF/A-3 that carries the human-readable invoice and the embedded Factur-X / ZUGFeRD XML - then deliver it to your customer.Stripe webhook (invoice.finalized) → your handler → PolyDoc API → email / upload
Register a webhook for invoice.finalized
In the Stripe Dashboard, go to Developers → Webhooks → Add endpoint. Point it at your handler URL and subscribe to the invoice.finalized event (Stripe fires this once an invoice is finalized and its totals are locked). Copy the endpoint's signing secret (whsec_…) - you'll need it in Step 2.
Verify the webhook signature
Your handler receives the POST. Before trusting it, verify the Stripe-Signature header against your endpoint's signing secret - Stripe's SDK does this in one call:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
// rawBody MUST be the raw request bytes, not the parsed JSON.
const event = stripe.webhooks.constructEvent(
rawBody,
request.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SIGNING_SECRET, // whsec_...
);
if (event.type !== "invoice.finalized") return; // ignore everything else
Fetch the full invoice
The webhook payload can be trimmed for large invoices, so re-fetch the full object and expand the pieces you need for the e-invoice - the customer, its tax IDs, and each line's tax rate - in a single call:
curl https://api.stripe.com/v1/invoices/$INVOICE_ID \
-u "$STRIPE_SECRET_KEY:" \
-d "expand[]=customer" \
-d "expand[]=customer.tax_ids" \
-d "expand[]=lines.data.tax_amounts.tax_rate" \
-G
Map the invoice and call PolyDoc
Map the Stripe invoice to PolyDoc's eInvoice block. The seller is your own business (fetch it once from GET /v1/accounts or hardcode it) - everything else comes from the invoice:
PolyDoc eInvoice.invoice | Stripe source | Transform |
|---|---|---|
number | invoice.number | direct |
issueDate | invoice.created (unix) | new Date(s*1000).toISOString().slice(0,10) |
dueDate | invoice.due_date (nullable) | same; omit if null |
currencyCode | invoice.currency | .toUpperCase() |
buyer.name / .email | invoice.customer_name / customer_email | direct |
buyer.address | invoice.customer_address | postal_code→postalCode, country→countryCode |
buyer.taxId | invoice.customer_tax_ids[0].value | first VAT id, if any |
lines[] | invoice.lines.data[] | .map() (below) |
lines[].unitPrice / .lineTotal | line.price.unit_amount / line.amount | / 100 (minor → major units) |
lines[].vatRate | line.tax_amounts[0].tax_rate.percentage | direct |
totalNetAmount / TaxAmount / GrossAmount | invoice.subtotal / tax / total | / 100 |
Then POST to PolyDoc. source renders the visual invoice from your template; eInvoice generates and embeds the CII XML; verify: true runs VeraPDF and fails the request if the output isn't PDF/A-3b compliant:
const inv = stripeInvoice; // the expanded object from Step 3
const div = 100; // 1 for zero-decimal currencies
const body = {
source: "[template:YOUR_TEMPLATE_ID]",
templateData: {
invoice_number: inv.number,
invoice_date: new Date(inv.created * 1000).toISOString().slice(0, 10),
invoice_total: inv.total / div,
customer_name: inv.customer_name,
customer_email: inv.customer_email,
items: inv.lines.data.map((l) => ({
quantity: l.quantity,
name: l.description,
description: l.description,
price: l.price.unit_amount / div,
})),
},
eInvoice: {
standard: "facturx", // or "zugferd"
profile: "en16931",
verify: true,
invoice: {
number: inv.number,
issueDate: new Date(inv.created * 1000).toISOString().slice(0, 10),
dueDate: inv.due_date
? new Date(inv.due_date * 1000).toISOString().slice(0, 10)
: undefined,
currencyCode: inv.currency.toUpperCase(),
seller: YOUR_BUSINESS, // { name, address, taxId } - your own, set once
buyer: {
name: inv.customer_name,
email: inv.customer_email,
address: {
line1: inv.customer_address.line1,
line2: inv.customer_address.line2 ?? undefined,
city: inv.customer_address.city,
postalCode: inv.customer_address.postal_code,
countryCode: inv.customer_address.country,
},
taxId: inv.customer_tax_ids?.[0]?.value,
},
lines: inv.lines.data.map((l) => ({
description: l.description,
quantity: l.quantity,
unitCode: "C62", // "HUR" for hours, "DAY" for days, etc.
unitPrice: l.price.unit_amount / div,
lineTotal: l.amount / div,
vatRate: l.tax_amounts?.[0]?.tax_rate?.percentage ?? 0,
vatCategoryCode: "S",
})),
totalNetAmount: inv.subtotal / div,
totalTaxAmount: inv.tax / div,
totalGrossAmount: inv.total / div,
},
},
};
const res = await fetch("https://api.polydoc.tech/pdf/convert", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
"X-Sandbox": "true", // drop in production
},
body: JSON.stringify(body),
});
const pdf = Buffer.from(await res.arrayBuffer()); // the PDF/A-3 hybrid
Deliver the hybrid PDF
Deliver the hybrid PDF. Two common options:
a) Email it to the customer - attach the PDF buffer from Step 4 to your transactional email (SES, Resend, Postmark, …). The single file is both the readable invoice and the compliant e-invoice, so one attachment covers both.
b) Store it back on the Stripe record - upload the PDF to Stripe and keep the reference alongside the invoice:
curl https://api.stripe.com/v1/files \
-u "$STRIPE_SECRET_KEY:" \
-F "purpose=customer_signature" \
-F "file=@invoice.pdf;type=application/pdf"
Use Case 2: Bulk Historical PDF/A-3 Archive
Back-fill a compliant PDF/A-3 archive for invoices you've already issued - useful for the EU's multi-year invoice-retention requirements - writing each archive straight to your own cloud bucket.List finalized invoices → PolyDoc API (+ cloudStorage) → PDF/A-3b in your bucket
List the invoices to archive
Decide the window to archive (e.g. the last fiscal year) and list the finalized/paid invoices. With the Stripe CLI:
stripe invoices list \
--status=paid \
--created.gte=1704067200 \
--limit=100
Page through with starting_after until you've collected every invoice id in range.
Generate and store each archive
For each invoice, fetch + map it exactly as in Use Case 1, then POST to PolyDoc with a cloudStorage.presignedUrl - a pre-signed PUT URL you generate for your S3 / R2 / GCS / Azure bucket. PolyDoc renders, builds the hybrid, and PUTs it there; the response is just a status:
for (const id of invoiceIds) {
const inv = await fetchExpandedInvoice(id); // Step 3 fetch
const body = mapToPolydoc(inv); // Step 4 mapping
body.cloudStorage = {
presignedUrl: await presignPut(`invoices/${inv.number}.pdf`),
};
await fetch("https://api.polydoc.tech/pdf/convert", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
}
See the cloud-storage guides for generating presigned URLs per provider.
Index the archive
Keep a small index so you can find any archived invoice later - map the Stripe number to its archive location and retention deadline:
| invoice_number | archive_url | issued | retain_until |
|---|---|---|---|
| INV-2026-0001 | s3://invoices/INV-2026-0001.pdf | 2026-01-15 | 2036-01-15 |
| INV-2026-0002 | s3://invoices/INV-2026-0002.pdf | 2026-01-18 | 2036-01-18 |