A developer guide for Shopify merchants: design a DOCX order confirmation template, call the BulkRender API from your orders/create webhook, and get the PDF into the customer's inbox. Roughly 60 lines of code end to end.
Shopify's built-in order confirmation email is a Liquid template. It is fine for a receipt, but it is not a document: customers cannot file it, forward it to accounts payable, or print it as a branded PDF. This guide wires up a proper one.
The design of the document lives in Word, not in your codebase. Your marketing team can restyle the confirmation and re-upload the template without a deploy.
Prefer no code?
If you want ad-hoc batches rather than an automated per-order flow, use an AI agent over MCP instead: see the Shopify agent guide. This page is the automated, production path.
orders/create topic, plus its webhook signing secret.GET /api/billing/credits.
Open Word, lay out the confirmation the way you want it printed: logo, order header, a line-item table, totals, shipping address, returns policy. Then replace every dynamic value with a {{placeholder}}.
Order {{order_number}}
Placed {{order_date}}
Bill to: {{customer_name}}
{{shipping_address}}
{{#line_items}}
{{title}} {{sku}} x{{quantity}} {{line_total}}
{{/line_items}}
Subtotal {{subtotal}}
Shipping {{shipping_total}}
Tax {{tax_total}}
Total {{total}}
Questions? Reply to {{support_email}}
The line-item table is a loop: wrap the repeating table row in {{#line_items}} and {{/line_items}}, and use the item's own fields inside it. At render time you pass an array of objects and BulkRender repeats the row once per element.
curl -X POST https://api.bulkrender.com/api/templates \ -H "X-API-Key: YOUR_API_KEY" \ -F "file=@order-confirmation.docx" \ -F "name=Shopify Order Confirmation"
The response contains the template id. Store it as BULKRENDER_TEMPLATE_ID in your environment. Call GET /api/templates/:id once to confirm the parsed variable list matches what you expect before you wire up the webhook.
Public templates will not work here
IDs from GET /api/templates/public belong to the walk-in checkout flow. Generating with an organization API key requires a template owned by your organization.
Subscribe to orders/create in your app config (or Shopify Admin, Settings, Notifications, Webhooks) and point it at https://your-app.example.com/webhooks/orders-create.
Shopify signs every webhook with your app's secret. Verify it against the raw body, before any JSON parsing, and compare in constant time. An unverified endpoint means anyone can make you spend credits.
import express from "express";
import crypto from "node:crypto";
const app = express();
function verifyShopifyHmac(rawBody, header) {
const digest = crypto
.createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET)
.update(rawBody)
.digest("base64");
const a = Buffer.from(digest);
const b = Buffer.from(header ?? "", "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post(
"/webhooks/orders-create",
express.raw({ type: "application/json" }),
async (req, res) => {
if (!verifyShopifyHmac(req.body, req.get("X-Shopify-Hmac-Sha256"))) {
return res.sendStatus(401);
}
// Ack within 5s or Shopify retries; do the work off the request path.
res.sendStatus(200);
const order = JSON.parse(req.body.toString("utf8"));
queue.add("order-pdf", { order }); // BullMQ, SQS, whatever you run
}
); Shopify money fields are strings in shop currency, so format them before they reach the template: the document renders whatever you send.
const money = (amount, currency) =>
new Intl.NumberFormat("en-GB", { style: "currency", currency }).format(
Number(amount ?? 0)
);
export async function generateOrderPdf(order) {
const cur = order.currency;
const ship = order.shipping_address ?? order.billing_address ?? {};
const data = {
order_number: order.name, // "#1042"
order_date: new Date(order.created_at).toLocaleDateString("en-GB", {
day: "numeric", month: "long", year: "numeric",
}),
customer_name:
`${order.customer?.first_name ?? ""} ${order.customer?.last_name ?? ""}`.trim(),
shipping_address: [ship.address1, ship.address2, ship.city, ship.zip, ship.country]
.filter(Boolean)
.join("\n"),
line_items: order.line_items.map((li) => ({
title: li.title,
sku: li.sku ?? "",
quantity: String(li.quantity),
line_total: money(Number(li.price) * li.quantity, cur),
})),
subtotal: money(order.subtotal_price, cur),
shipping_total: money(
order.total_shipping_price_set?.shop_money?.amount ?? 0, cur
),
tax_total: money(order.total_tax, cur),
total: money(order.total_price, cur),
support_email: "support@yourstore.com",
email: order.email, // triggers auto-delivery
};
const res = await fetch("https://api.bulkrender.com/api/documents/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.BULKRENDER_API_KEY,
"Idempotency-Key": `shopify-order-${order.id}`,
},
body: JSON.stringify({
templateId: process.env.BULKRENDER_TEMPLATE_ID,
data,
outputFormat: "pdf",
name: `Order-${order.order_number}`,
}),
});
if (!res.ok) throw new Error(`BulkRender ${res.status}: ${await res.text()}`);
const { data: doc } = await res.json();
return doc; // { id, url, file_size, emailQueued, ... }
}
The Idempotency-Key keyed on the Shopify order ID matters: Shopify retries a webhook if your endpoint is slow or errors, and without the key each retry burns 2 more credits and produces a duplicate document.
Unresolved variables
If the response includes an unresolvedVariables array, your template has placeholders your mapping never filled. Log it, it is the fastest way to catch a renamed field after a template edit. The check is key-based and covers main-scope placeholders only: a key you sent as an empty string counts as resolved (and renders blank), and variables inside loops are not listed. The array is omitted when nothing is unresolved.
If your data object contains an email field, BulkRender emails the customer a download link automatically. That is the email: order.email line above. Using a different field name? Pass recipientEmailField in the request body.
201 with emailQueued: true as soon as it is handed off, and sends are counted against your plan's monthly email quota. If the quota is exhausted, emailQueued comes back false and the document is still generated. The field is omitted entirely when no recipient address was resolved, so treat a missing emailQueued as "no email was attempted" and re-check your email / recipientEmailField mapping.Most merchants want the PDF attached to their own branded email, sent from their own domain. Omit the email field, download the signed URL, and attach the bytes.
import nodemailer from "nodemailer";
export async function emailOrderPdf(order, doc) {
const pdf = Buffer.from(await (await fetch(doc.url)).arrayBuffer());
const transport = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: 587,
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS },
});
await transport.sendMail({
from: '"Your Store" <orders@yourstore.com>',
to: order.email,
subject: `Your order ${order.name} is confirmed`,
text: `Thanks for your order. Your confirmation is attached.`,
attachments: [
{ filename: `Order-${order.order_number}.pdf`, content: pdf },
],
});
}
The signed url expires after 1 hour, so download it in the same job that generated the document. If you need the link later, call GET /api/documents/:id/refresh-url for a fresh URL. Re-signing costs no credits; regenerating does.
| Symptom | Cause and fix |
|---|---|
| 401 from your own endpoint | HMAC computed over a parsed body. Mount express.raw() on the webhook route only, before any global JSON body parser. |
| 401 from BulkRender | Missing, invalid, or expired X-API-Key. Note the header is X-API-Key, not Authorization. |
| Placeholders left in the PDF | Word split the placeholder across formatting runs, or the name does not match. Retype the placeholder in one go, and check unresolvedVariables in the response. |
| Line items render once | Loop tags are not around the repeating table row, or line_items was sent as a string. It must be an array of objects. |
| Duplicate confirmations | Shopify retried a webhook that timed out. Ack within 5 seconds and set Idempotency-Key. |
| Download link 403s | Signed URL older than 1 hour. Call GET /api/documents/:id/refresh-url. |
| emailQueued: false | Your plan's monthly email quota is used up, or the plan includes no sends. Send the PDF yourself (Option B) or upgrade. |
Create an account, upload your first template, and generate your first document in under five minutes.