Shopify does not send branded PDF invoices automatically [1]. Order Printer templates sit behind the admin panel, and invoice apps come with their own layout constraints. The clean path is a webhook you control: Shopify pushes the order payload to your endpoint, you map it to an HTML template, and render a PDF with one API call.
Subscribe to the orders/create webhook through your Shopify admin or the GraphQL Admin API. This topic fires when a new order is placed [2].
Every delivery includes a base64-encoded HMAC-SHA256 signature in the X-Shopify-Hmac-SHA256 header, computed from the raw request body and your app's shared secret [2]. Verify this before processing. Run verification middleware before body parsers like express.json() because the signature requires the raw body string [3].
const crypto = require('crypto');
function verifyShopifyWebhook(body, hmac, secret) {
const hash = crypto
.createHmac('sha256', secret)
.update(body, 'utf8')
.digest('base64');
return crypto.timingSafeEqual(
Buffer.from(hash),
Buffer.from(hmac)
);
}
HMAC verification applies only to HTTPS deliveries [3].
Dedupe on X-Shopify-Webhook-Id to avoid double-processing the same order [2]. If a topic has multiple subscriptions, each delivery gets a different webhook ID but shares the same event ID [3].
Return a 2xx status immediately. Shopify enforces a one-second connection timeout and a five-second total timeout, treating anything outside the 200 range as an error [3]. Process the payload asynchronously after responding [2]. Shopify retries failed deliveries up to 8 times over 4 hours with exponential backoff [2], and after 8 consecutive failures, subscriptions created via the Admin API are automatically deleted with warning emails sent to your developer emergency address [3]. Rotating your client secret can take up to an hour before HMAC digests use the new secret [3].
Create a template in MarkupGo using HTML and CSS with {{ variable }} placeholders for customer details, line items, totals, tax, and shipping [4]. Store the template ID from your dashboard for the API call.
Design for B2B fields that apps often omit: VAT ID, purchase order number, custom line-item columns, and brand fonts. A single template handles every order when you pass dynamic data through the JSON context field.
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Inter', sans-serif; margin: 40px; color: #1a1a1a; }
.header { display: flex; justify-content: space-between; margin-bottom: 48px; }
.vat { color: #666; font-size: 13px; }
table { width: 100%; border-collapse: collapse; margin-top: 24px; }
th { text-align: left; padding: 12px 0; border-bottom: 2px solid #e5e5e5; }
td { padding: 12px 0; border-bottom: 1px solid #f0f0f0; }
.totals { margin-top: 32px; text-align: right; }
.total { font-size: 20px; font-weight: 600; }
</style>
</head>
<body>
<div class="header">
<div>
<h1>INVOICE</h1>
<p class="vat">VAT ID: {{ seller_vat }}</p>
</div>
<div>
<p>Invoice #{{ order_number }}</p>
<p>{{ date }}</p>
<p class="vat">PO: {{ purchase_order }}</p>
</div>
</div>
<p><strong>Bill to:</strong><br>
{{ customer_name }}<br>
{{ customer_email }}<br>
VAT ID: {{ customer_vat }}</p>
<table>
<thead>
<tr>
<th>Item</th>
<th>Qty</th>
<th>Unit Price</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{{#each line_items}}
<tr>
<td>{{ title }}</td>
<td>{{ quantity }}</td>
<td>{{ price }}</td>
<td>{{ line_total }}</td>
</tr>
{{/each}}
</tbody>
</table>
<div class="totals">
<p>Subtotal: {{ subtotal }}</p>
<p>Tax ({{ tax_rate }}%): {{ tax_amount }}</p>
<p>Shipping: {{ shipping }}</p>
<p class="total">Total: {{ total }}</p>
</div>
</body>
</html>
Pull order_number, email, total_price, tax_lines, line_items, and nested customer fields into the structure your template expects.
POST the template ID and context object to MarkupGo's /pdf endpoint [5]. Include your API key in the x-api-key header [6].
const response = await fetch('https://api.markupgo.com/api/v1/pdf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.MARKUPGO_API_KEY
},
body: JSON.stringify({
source: {
type: 'template',
data: {
id: process.env.MARKUPGO_INVOICE_TEMPLATE_ID,
context: {
order_number: order.name,
date: new Date().toISOString().split('T')[0],
customer_name: `${order.customer.first_name} ${order.customer.last_name}`,
customer_email: order.email,
customer_vat: order.customer?.default_address?.company || 'N/A',
seller_vat: process.env.SELLER_VAT_ID,
purchase_order: order.note || 'N/A',
line_items: order.line_items.map(item => ({
title: item.title,
quantity: item.quantity,
price: item.price,
line_total: (item.quantity * item.price).toFixed(2)
})),
subtotal: order.subtotal_price,
tax_rate: order.tax_lines[0]?.rate * 100 || 0,
tax_amount: order.total_tax,
shipping: order.total_shipping_price_set?.shop_money?.amount || 0,
total: order.total_price
}
}
},
options: {
properties: {
size: { width: 210, height: 297 },
margins: { top: 10, bottom: 10, left: 10, right: 10 }
},
// see Markupgo's API reference for additional options
},
expiration: { expiresInSeconds: 604800 }
})
});
const task = await response.json();
const pdfUrl = task.url;
Override template options per request if needed: margins, header, footer, page size [5][4]. PDF options also include PDF/UA accessibility, metadata, cookies, and wait conditions [4].
Set expiresInSeconds so invoice PDFs containing customer PII auto-delete after your chosen window, from 60 seconds up to 90 days [5]. Expired files are swept every minute, so a PDF may remain reachable for roughly 60 seconds past its requested expiration [5]. Deleting counts as a normal delete with no credit refund.
Send via your existing email provider or transactional service; no Shopify app required.
// Example with any HTTP-based email service
await fetch('https://api.your-email-service.com/send', {
method: 'POST',
headers: { 'Authorization': `Bearer ${process.env.EMAIL_API_KEY}` },
body: JSON.stringify({
to: order.email,
subject: `Invoice ${order.name} from ${process.env.STORE_NAME}`,
attachments: [{
url: pdfUrl,
filename: `invoice-${order.name}.pdf`
}]
})
});
For EU stores, pair the expiration setting with your data retention policy to limit PII storage without building extra infrastructure.
MarkupGo's Lite plan costs $29/month and includes 2,000 credits and 5 templates [4]. Plus runs $49/month for 10,000 credits and 30 templates; Pro is $99/month for 20,000 credits and unlimited templates [4]. The free trial grants 100 credits every month indefinitely for testing the full flow [4].
A store doing 100 orders per month uses roughly 100 credits for invoice generation, a small slice of the Lite plan's 2,000 monthly credits [4].
Add payment QR codes to the same template using MarkupGo's existing UPI, EPC, or SEPA guide. Connect the same webhook payload to Make.com or n8n for multi-step automation beyond email. Apply this pattern to packing slips, refund notices, or B2B quote PDFs using the same template system and expiration controls.