Skip to main content

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

admin.shopify.com

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.invoiceShopify order fieldTransform
numbername / order_numberdirect (string)
issueDatecreated_at.slice(0,10) (already ISO 8601)
currencyCodecurrency.toUpperCase()
buyer.namecustomer.first_name + last_namejoin with a space
buyer.emailcustomer.email ?? emaildirect
buyer.addressbilling_addresszip→postalCode, country_code→countryCode
lines[]line_items[].map() (below)
lines[].unitPriceline.priceparseFloat (decimal string, no /100)
lines[].lineTotalline.price × line.quantitycomputed
lines[].vatRateline.tax_lines[0].rate× 100 (fraction → percent)
totalNetAmount / TaxAmount / GrossAmountsubtotal_price / total_tax / total_priceparseFloat

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_numberarchive_urlissuedretain_until
#1001s3://invoices/1001.pdf2026-01-152036-01-15
#1002s3://invoices/1002.pdf2026-01-182036-01-18

Next steps​