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