Skip to main content

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 source field (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.

dashboard.stripe.com

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.invoiceStripe sourceTransform
numberinvoice.numberdirect
issueDateinvoice.created (unix)new Date(s*1000).toISOString().slice(0,10)
dueDateinvoice.due_date (nullable)same; omit if null
currencyCodeinvoice.currency.toUpperCase()
buyer.name / .emailinvoice.customer_name / customer_emaildirect
buyer.addressinvoice.customer_addresspostal_code→postalCode, country→countryCode
buyer.taxIdinvoice.customer_tax_ids[0].valuefirst VAT id, if any
lines[]invoice.lines.data[].map() (below)
lines[].unitPrice / .lineTotalline.price.unit_amount / line.amount/ 100 (minor → major units)
lines[].vatRateline.tax_amounts[0].tax_rate.percentagedirect
totalNetAmount / TaxAmount / GrossAmountinvoice.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_numberarchive_urlissuedretain_until
INV-2026-0001s3://invoices/INV-2026-0001.pdf2026-01-152036-01-15
INV-2026-0002s3://invoices/INV-2026-0002.pdf2026-01-182036-01-18