Shopify
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 Shopify store with a custom app (or webhook access). Start a development store while you build this flow.
Use Case 1: Compliant E-Invoice on Order Payment
Turn a paid Shopify 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.Shopify webhook (orders/paid) → your handler → PolyDoc API → email / upload
Register a webhook for orders/paid
In the Shopify admin, go to Settings → Notifications → Webhooks → Create webhook. Choose the Order payment event (topic orders/paid), set the format to JSON, and point it at your handler URL. After saving, Shopify reveals a signing secret near the webhook list. Copy it; you'll need it in Step 2 to verify the HMAC.
Verify the webhook HMAC
Your handler receives the POST. Before trusting it, verify the X-Shopify-Hmac-Sha256 header: compute an HMAC-SHA256 of the raw request body with your webhook signing secret, base64-encode it, and compare it to the header in constant time.
import crypto from "node:crypto";
// rawBody MUST be the raw request bytes, not the parsed JSON.
function verifyShopifyWebhook(rawBody, hmacHeader) {
const digest = crypto
.createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET)
.update(rawBody, "utf8")
.digest("base64");
return crypto.timingSafeEqual(
Buffer.from(digest),
Buffer.from(hmacHeader),
);
}
if (!verifyShopifyWebhook(rawBody, request.headers["x-shopify-hmac-sha256"])) {
return new Response("invalid signature", { status: 401 });
}
const order = JSON.parse(rawBody);
if (order.financial_status !== "paid") return; // see the guard below
Read the order from the payload
Unlike most invoicing APIs, the orders/paid payload is the full order object: customer, billing address, line items, and tax lines all arrive inline, so there's no second fetch. 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 business (your shop's legal entity, set once). Everything else comes from the order:
PolyDoc eInvoice.invoice | Shopify order field | Transform |
|---|---|---|
number | name / order_number | direct (string) |
issueDate | created_at | .slice(0,10) (already ISO 8601) |
currencyCode | currency | .toUpperCase() |
buyer.name | customer.first_name + last_name | join with a space |
buyer.email | customer.email ?? email | direct |
buyer.address | billing_address | zip→postalCode, country_code→countryCode |
lines[] | line_items[] | .map() (below) |
lines[].unitPrice | line.price | parseFloat (decimal string, no /100) |
lines[].lineTotal | line.price × line.quantity | computed |
lines[].vatRate | line.tax_lines[0].rate | × 100 (fraction → percent) |
totalNetAmount / TaxAmount / GrossAmount | subtotal_price / total_tax / total_price | 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.taxes_included === true;
const lines = order.line_items.map((l) => {
const rate = parseFloat(l.tax_lines?.[0]?.rate ?? 0); // 0.19
const gross = parseFloat(l.price); // "19.99" => 19.99
const unitPrice = inclusive ? gross / (1 + rate) : gross;
return {
description: [l.title, l.variant_title].filter(Boolean).join(" / "),
quantity: l.quantity,
unitCode: "C62", // "HUR" for hours, "DAY" for days, etc.
unitPrice,
lineTotal: unitPrice * l.quantity,
vatRate: rate * 100, // fraction to percent (0.19 => 19)
vatCategoryCode: "S",
};
});
const body = {
source: "[template:YOUR_TEMPLATE_ID]",
templateData: {
invoice_number: order.name,
invoice_date: order.created_at.slice(0, 10),
invoice_total: parseFloat(order.total_price),
customer_name: `${order.customer.first_name} ${order.customer.last_name}`,
customer_email: order.customer.email ?? order.email,
items: order.line_items.map((l) => ({
quantity: l.quantity,
name: l.title,
description: [l.title, l.variant_title].filter(Boolean).join(" / "),
price: parseFloat(l.price),
})),
},
eInvoice: {
standard: "facturx", // or "zugferd"
profile: "en16931",
verify: true,
invoice: {
number: String(order.order_number ?? order.name),
issueDate: order.created_at.slice(0, 10),
paymentTerms: "Paid in full via Shopify", // BT-9/BT-20: see note
currencyCode: order.currency.toUpperCase(),
seller: YOUR_BUSINESS, // { name, address, taxId }: your own, set once
buyer: {
name: `${order.customer.first_name} ${order.customer.last_name}`,
email: order.customer.email ?? order.email,
address: {
line1: order.billing_address.address1,
line2: order.billing_address.address2 ?? undefined,
city: order.billing_address.city,
postalCode: order.billing_address.zip,
countryCode: order.billing_address.country_code,
},
},
lines,
totalNetAmount: parseFloat(order.subtotal_price),
totalTaxAmount: parseFloat(order.total_tax),
totalGrossAmount: parseFloat(order.total_price),
},
},
};
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 order. Upload the PDF to your own storage and attach the link to the order via a Shopify metafield (REST POST /admin/api/<version>/orders/<id>/metafields.json or the GraphQL metafieldsSet mutation), so the archive link lives next to the order:
curl -X POST \
"https://your-shop.myshopify.com/admin/api/2026-01/orders/$ORDER_ID/metafields.json" \
-H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"metafield":{"namespace":"einvoice","key":"pdf_url","type":"url","value":"https://…/INV-1001.pdf"}}'
Use Case 2: Bulk Historical PDF/A-3 Archive
Back-fill a compliant PDF/A-3 archive for orders you've already fulfilled (useful for the EU's multi-year invoice-retention requirements), writing each archive straight to your own cloud bucket.List paid 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 paid orders. With the Admin REST API:
curl -G \
"https://your-shop.myshopify.com/admin/api/2026-01/orders.json" \
-H "X-Shopify-Access-Token: $SHOPIFY_ADMIN_TOKEN" \
-d "status=any" \
-d "financial_status=paid" \
-d "created_at_min=2025-01-01T00:00:00Z" \
-d "limit=250"
Page through with the Link: …rel="next" response header (cursor pagination) until you've collected every order in range.
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 paidOrders) {
const body = mapToPolydoc(order); // Step 4 mapping
body.cloudStorage = {
presignedUrl: await presignPut(`invoices/${order.name}.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 |
|---|---|---|---|
| #1001 | s3://invoices/1001.pdf | 2026-01-15 | 2036-01-15 |
| #1002 | s3://invoices/1002.pdf | 2026-01-18 | 2036-01-18 |