Guides · Shopify + API

Shopify PDF generation for order confirmations.

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.

Overview

What You Are Building

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.

Step 1
DOCX template
Design the confirmation in Word with placeholder variables, upload it to BulkRender, keep the templateId.
Step 2
Webhook handler
Shopify posts orders/create to your endpoint. You verify the HMAC, map the order, call the API.
Step 3
Deliver the PDF
Let BulkRender email a download link, or take the signed URL and send it through your own mail provider.

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.

Before you start

Prerequisites

  • A BulkRender account and an API key from Settings, Integrations. Uploading templates needs an editor-or-above key.
  • A Shopify custom app (or Shopify Admin webhook) that can subscribe to the orders/create topic, plus its webhook signing secret.
  • A publicly reachable HTTPS endpoint. Node 18+ examples below; any language works, it is one POST.
  • Credits: each PDF costs 2 credits, each DOCX costs 1. Check your balance with GET /api/billing/credits.
Step 1

Create the DOCX Template

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}}.

Placeholder variables

order-confirmation.docx
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.

Upload it

$ Terminal
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.

Step 2

Call BulkRender from the Shopify Webhook

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.

Verify the HMAC first

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.

webhook.js
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
  }
);

Map the order and generate

Shopify money fields are strings in shop currency, so format them before they reach the template: the document renders whatever you send.

order-pdf-worker.js
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.

Step 3

Get the PDF to the Customer

Option A: let BulkRender deliver it

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.

  • The email carries a link, not an attachment. The link signature lasts 7 days, but the file itself only lives as long as your organization's document retention window — 3 days by default, configurable in Settings — after which a weekly cleanup job deletes it and the link 404s. If customers need the PDF for longer than your retention window, use Option B and send the file as an attachment.
  • Sender name, reply-to address, default subject, and message body come from your organization's email settings in the dashboard.
  • Delivery is fire-and-forget: the API responds 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.

Option B: send it yourself, as an attachment

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.

send-order-pdf.js
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.

Production

Hardening the Flow

  • Ack fast, generate async: Shopify expects a 2xx within 5 seconds and retries for up to 48 hours otherwise. Return 200 immediately after HMAC verification and do the generation in a background job.
  • Always set an idempotency key: Key it on the Shopify order ID. Retries then return the cached result instead of generating and charging twice. Note that fixing bad data requires a NEW key: successful responses are cached as-is.
  • Store the documentId against the order: Save the returned document id on your order record. Customer support can then re-send the confirmation with a refresh-url call, no regeneration and no credit spend.
  • Watch your credit balance: Every generate response includes X-Credits-Remaining. Alert on it. A 403 for exhausted credits during Black Friday is a bad way to find out.
  • Use orders/paid if you bill on capture: orders/create fires on order creation, including unpaid draft-to-order conversions. If the confirmation should only exist for captured payments, subscribe to orders/paid instead.
  • Batch the backfill: Generating confirmations for historical orders? Use POST /api/documents/generate-batch with an array of records rather than one call per order. It returns a single ZIP and is far fewer round trips.
Troubleshooting

Common Problems

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.

Ready to start?

Create an account, upload your first template, and generate your first document in under five minutes.