WooCommerce
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 WordPress site with WooCommerce installed and REST API access (Settings, Advanced, REST API). Get WooCommerce, it is free.
Use Case 1: Compliant E-Invoice on a Completed Order
Turn a completed WooCommerce order 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.WooCommerce (order updated) → your handler → PolyDoc API → email / order Note
Register a webhook for Order updated
In wp-admin, go to WooCommerce → Settings → Advanced → Webhooks → Add webhook. Set:
- Topic:
Order updated - Delivery URL: your handler URL (must be HTTPS)
- Secret: a long random string (or let WooCommerce generate one). You need this in Step 2 to verify the HMAC.
- API Version: WP REST API Integration v3
- Status: Active
Verify the webhook HMAC
Your handler receives the POST. Before trusting it, verify the X-WC-Webhook-Signature header: compute an HMAC-SHA256 of the raw request body with your webhook secret, base64-encode it, and compare in constant time. Then guard on the order status so you only invoice completed orders:
import crypto from "node:crypto";
// rawBody MUST be the raw request bytes, not the parsed JSON.
function verifyWooCommerceWebhook(rawBody, signatureHeader) {
const digest = crypto
.createHmac("sha256", process.env.WC_WEBHOOK_SECRET)
.update(rawBody, "utf8")
.digest("base64");
return crypto.timingSafeEqual(
Buffer.from(digest),
Buffer.from(signatureHeader),
);
}
if (!verifyWooCommerceWebhook(rawBody, request.headers["x-wc-webhook-signature"])) {
return new Response("invalid signature", { status: 401 });
}
const order = JSON.parse(rawBody);
if (order.status !== "completed") return; // or "processing" if you invoice on payment
Read the order from the payload
WooCommerce webhook v3 delivers the full order object, so there is no second fetch in Use Case 1. The fields the next step maps from:
Map the order and call PolyDoc
Map the order to PolyDoc's eInvoice block. The seller is your own shop (your legal entity: set it once). Everything else comes from the order:
PolyDoc eInvoice.invoice | WooCommerce source | Transform |
|---|---|---|
number | order number | direct (string; merchant-facing) |
issueDate | date_completed (else date_created) | .slice(0,10) (ISO 8601) |
currencyCode | currency | .toUpperCase() |
buyer.name | billing.company else first_name + last_name | prefer company for B2B |
buyer.email | billing.email | direct |
buyer.address | billing.{address_1,city,postcode,country} | postcode→postalCode, country→countryCode (already ISO-2) |
lines[] | line_items[] | .map() (below) |
lines[].unitPrice | line price | parseFloat; if prices_include_tax, divide out tax |
lines[].lineTotal | line subtotal (pre-discount) or total | parseFloat, tax-adjusted |
lines[].vatRate | from line.taxes[] + order.tax_lines[], else total_tax / subtotal x 100 | direct percent |
totalNet / Tax / Gross | order subtotal / total_tax / total | parseFloat |
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 order = JSON.parse(rawBody); // the verified order from Step 2
const inclusive = order.prices_include_tax === true;
// Order-level rate from tax_lines (most stores have one); fall back to a compute.
const orderRate =
parseFloat(order.tax_lines?.[0]?.rate_percent ?? 0) ||
(parseFloat(order.total_tax) / parseFloat(order.subtotal)) * 100 ||
0;
const lines = order.line_items.map((l) => {
const lineRate =
parseFloat(l.taxes?.[0]?.rate_id && order.tax_lines?.find((t) => t.rate_id === l.taxes[0].rate_id)?.rate_percent) ||
orderRate;
const listed = parseFloat(l.price);
const unitPrice = inclusive ? listed / (1 + lineRate / 100) : listed;
return {
description: l.name,
quantity: l.quantity,
unitCode: "C62", // "HUR" for hours, "DAY" for days, etc.
unitPrice,
lineTotal: unitPrice * l.quantity,
vatRate: lineRate,
vatCategoryCode: lineRate > 0 ? "S" : "Z",
};
});
const issueDate = (order.date_completed ?? order.date_created).slice(0, 10);
const dueDate = order.date_paid
? order.date_paid.slice(0, 10)
: new Date(new Date(issueDate).getTime() + 30 * 864e5).toISOString().slice(0, 10);
const body = {
source: "[template:YOUR_TEMPLATE_ID]",
templateData: {
invoice_number: order.number,
invoice_date: issueDate,
invoice_total: parseFloat(order.total),
customer_name: order.billing.company || `${order.billing.first_name} ${order.billing.last_name}`,
customer_email: order.billing.email,
items: order.line_items.map((l) => ({
quantity: l.quantity,
name: l.name,
description: l.name,
price: parseFloat(l.price),
})),
},
eInvoice: {
standard: "facturx", // or "zugferd"
profile: "en16931",
verify: true,
invoice: {
number: String(order.number),
issueDate,
dueDate,
paymentTerms: "Net 30 days", // BT-9/BT-20: see note
currencyCode: order.currency.toUpperCase(),
seller: YOUR_BUSINESS, // { name, address, taxId }: your own, set once
buyer: {
name: order.billing.company || `${order.billing.first_name} ${order.billing.last_name}`,
email: order.billing.email,
address: {
line1: order.billing.address_1,
line2: order.billing.address_2 || undefined,
city: order.billing.city,
postalCode: order.billing.postcode,
countryCode: order.billing.country, // ISO-2 already
},
},
lines,
totalNetAmount: parseFloat(order.subtotal),
totalTaxAmount: parseFloat(order.total_tax),
totalGrossAmount: parseFloat(order.total),
},
},
};
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) Attach it to the order as a Note. Upload the PDF to your own storage (or the WordPress Media library), then create a private Order Note linking to the archive so the document lives next to the record. With the Consumer Key + Secret from Step 1:
curl -u "$WC_CONSUMER_KEY:$WC_CONSUMER_SECRET" \
-X POST "https://your-shop.example/wp-json/wc/v3/orders/$ORDER_ID/notes" \
-H "Content-Type: application/json" \
-d '{"note":"E-invoice: https://.../WC-1042.pdf","customer_note":false}'
Use Case 2: Bulk Historical PDF/A-3 Archive
Back-fill a compliant PDF/A-3 archive for orders you've already completed (useful for the EU's multi-year invoice-retention requirements), writing each archive straight to your own cloud bucket.List completed orders → PolyDoc API (+ cloudStorage) → PDF/A-3b in your bucket
List the orders to archive
Decide the window to archive (e.g. the last fiscal year) and list the completed orders. Use the WooCommerce REST API with HTTP Basic Auth (your Consumer Key + Consumer Secret from Step 1):
curl -u "$WC_CONSUMER_KEY:$WC_CONSUMER_SECRET" -G \
"https://your-shop.example/wp-json/wc/v3/orders" \
-d "status=completed" \
-d "after=2025-01-01T00:00:00" \
-d "per_page=100" \
-d "page=1"
Page through with the page param (and the X-WP-TotalPages response header) until you've collected every order in range. The response already includes inline line_items[], so there is no second call per order.
Generate and store each archive
For each order, 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 order of completedOrders) {
const body = mapToPolydoc(order); // Step 4 mapping
body.cloudStorage = {
presignedUrl: await presignPut(`invoices/${order.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 order number to its archive location and retention deadline:
| order_number | archive_url | issued | retain_until |
|---|---|---|---|
| WC-1042 | s3://invoices/WC-1042.pdf | 2026-02-15 | 2036-02-15 |
| WC-1043 | s3://invoices/WC-1043.pdf | 2026-02-18 | 2036-02-18 |