Use case
E-commerce Order Confirmation PDF
Every paid order deserves a branded PDF confirmation your customer can save, forward, or reference months later. One webhook, one API call, and a permanent download link lands in their inbox. Works with Shopify, WooCommerce, or any platform that fires a payment event.
Order confirmation HTML template
A Mustache template covering logo, order number, line items with variant details, shipping address, order totals, and estimated delivery. Render it server-side with the order data, then POST the HTML to the API. No PDF library to install, no font configuration required.
<!-- templates/order-confirmation.html -->
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Inter', sans-serif; color: #1a1a1a; margin: 0; padding: 48px; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 48px; }
.logo { font-size: 24px; font-weight: 700; color: #d23a1d; }
.order-meta { text-align: right; font-size: 13px; color: #666; }
.order-number { font-size: 20px; font-weight: 600; color: #1a1a1a; }
.section { margin-bottom: 32px; }
.section h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: #999; margin-bottom: 8px; }
.two-col { display: flex; gap: 48px; margin-bottom: 40px; }
.two-col > div { flex: 1; }
table { width: 100%; border-collapse: collapse; margin-bottom: 32px; }
th { text-align: left; padding: 10px 0; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #999; border-bottom: 1px solid #e5e5e5; }
td { padding: 14px 0; font-size: 14px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }
.qty { text-align: center; }
.price { text-align: right; }
.totals { width: 260px; margin-left: auto; }
.totals tr td { border: none; padding: 5px 0; font-size: 14px; }
.total-row td { font-weight: 700; font-size: 16px; padding-top: 12px; border-top: 2px solid #1a1a1a; }
.delivery-banner { background: #f7f3ea; border-radius: 6px; padding: 16px 20px; margin-bottom: 32px; font-size: 13px; color: #555; }
.delivery-banner strong { color: #1a1a1a; }
.footer { margin-top: 64px; font-size: 12px; color: #999; border-top: 1px solid #e5e5e5; padding-top: 24px; }
@page { margin: 0; size: A4; }
</style>
</head>
<body>
<div class="header">
<div class="logo">{{storeName}}</div>
<div class="order-meta">
<div class="order-number">Order #{{orderNumber}}</div>
<div>Placed: {{orderDate}}</div>
<div>Status: {{orderStatus}}</div>
</div>
</div>
<div class="delivery-banner">
Estimated delivery: <strong>{{estimatedDelivery}}</strong> via {{shippingMethod}}
</div>
<div class="two-col">
<div class="section">
<h3>Ship to</h3>
<p style="font-size:14px;margin:0">
{{shippingName}}<br>
{{shippingLine1}}<br>
{{#shippingLine2}}{{shippingLine2}}<br>{{/shippingLine2}}
{{shippingCity}}, {{shippingState}} {{shippingZip}}<br>
{{shippingCountry}}
</p>
</div>
<div class="section">
<h3>Bill to</h3>
<p style="font-size:14px;margin:0">
{{billingName}}<br>
{{billingEmail}}<br>
{{billingPhone}}
</p>
</div>
</div>
<table>
<thead>
<tr>
<th>Product</th>
<th class="qty">Qty</th>
<th class="price">Unit price</th>
<th class="price">Total</th>
</tr>
</thead>
<tbody>
{{#lineItems}}
<tr>
<td>
{{productName}}
{{#variantTitle}}<br><span style="color:#999;font-size:12px">{{variantTitle}}</span>{{/variantTitle}}
</td>
<td class="qty">{{quantity}}</td>
<td class="price">{{unitPrice}}</td>
<td class="price">{{lineTotal}}</td>
</tr>
{{/lineItems}}
</tbody>
</table>
<table class="totals">
<tr><td>Subtotal</td><td class="price">{{subtotal}}</td></tr>
<tr><td>Shipping</td><td class="price">{{shippingCost}}</td></tr>
{{#discount}}<tr><td>Discount ({{discountCode}})</td><td class="price">-{{discount}}</td></tr>{{/discount}}
<tr><td>Tax</td><td class="price">{{tax}}</td></tr>
<tr class="total-row"><td>Order total</td><td class="price">{{orderTotal}}</td></tr>
</table>
<div class="footer">
Thank you for your order. Questions? Contact {{supportEmail}} or visit {{supportUrl}}
</div>
</body>
</html>Shopify orders/paid webhook handler
Register a webhook for the orders/paid topic in Shopify Admin. The handler verifies the HMAC signature, fills the template with order data, posts to the PDF API with store: true, and emails the customer a download link. The whole flow completes well within Shopify's 5-second webhook timeout.
// webhook/shopify-orders-paid.ts
// Register at: Shopify Admin > Settings > Notifications > Webhooks
// Topic: orders/paid
import Mustache from "mustache";
import { readFileSync } from "fs";
import crypto from "crypto";
const template = readFileSync("./templates/order-confirmation.html", "utf-8");
export async function handleShopifyOrderPaid(req: Request): Promise<Response> {
// Verify HMAC signature
const hmac = req.headers.get("x-shopify-hmac-sha256")!;
const rawBody = await req.text();
const digest = crypto
.createHmac("sha256", process.env.SHOPIFY_WEBHOOK_SECRET!)
.update(rawBody, "utf8")
.digest("base64");
if (!crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(digest))) {
return new Response("Unauthorized", { status: 401 });
}
const order = JSON.parse(rawBody);
const html = Mustache.render(template, {
storeName: process.env.STORE_NAME ?? "My Store",
orderNumber: order.order_number,
orderDate: new Date(order.created_at).toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
}),
orderStatus: "Confirmed",
estimatedDelivery: formatDeliveryWindow(order.created_at, 5),
shippingMethod: order.shipping_lines?.[0]?.title ?? "Standard",
shippingName: order.shipping_address?.name,
shippingLine1: order.shipping_address?.address1,
shippingLine2: order.shipping_address?.address2 || null,
shippingCity: order.shipping_address?.city,
shippingState: order.shipping_address?.province_code,
shippingZip: order.shipping_address?.zip,
shippingCountry: order.shipping_address?.country,
billingName: order.billing_address?.name,
billingEmail: order.email,
billingPhone: order.phone ?? order.billing_address?.phone,
lineItems: order.line_items.map((item: ShopifyLineItem) => ({
productName: item.title,
variantTitle: item.variant_title !== "Default Title" ? item.variant_title : null,
quantity: item.quantity,
unitPrice: formatMoney(item.price, order.currency),
lineTotal: formatMoney((parseFloat(item.price) * item.quantity).toFixed(2), order.currency),
})),
subtotal: formatMoney(order.subtotal_price, order.currency),
shippingCost: formatMoney(order.total_shipping_price_set?.shop_money?.amount ?? "0.00", order.currency),
discount: order.total_discounts !== "0.00" ? formatMoney(order.total_discounts, order.currency) : null,
discountCode: order.discount_codes?.[0]?.code ?? "",
tax: formatMoney(order.total_tax, order.currency),
orderTotal: formatMoney(order.total_price, order.currency),
supportEmail: process.env.SUPPORT_EMAIL,
supportUrl: process.env.SUPPORT_URL,
});
const pdfRes = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
filename: `order-${order.order_number}.pdf`,
store: true,
options: { format: "A4" },
}),
});
const { document_url, document_id, document_expires } = await pdfRes.json();
// Persist for your order management system
await db.orderDocuments.upsert({
shopifyOrderId: String(order.id),
pdfId: document_id,
pdfUrl: document_url,
expiresAt: new Date(document_expires),
});
// Email the customer a download link
await sendOrderEmail(order.email, {
customerName: order.shipping_address?.first_name ?? "there",
orderNumber: order.order_number,
downloadUrl: document_url,
});
return new Response(JSON.stringify({ received: true }), { status: 200 });
}
function formatMoney(amount: string, currency: string): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(parseFloat(amount));
}
function formatDeliveryWindow(createdAt: string, businessDays: number): string {
const start = new Date(createdAt);
start.setDate(start.getDate() + businessDays);
const end = new Date(start);
end.setDate(end.getDate() + 2);
return `${start.toLocaleDateString("en-US", { month: "short", day: "numeric" })} – ${end.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
}
interface ShopifyLineItem {
title: string;
variant_title: string;
quantity: number;
price: string;
}WooCommerce (PHP)
Hook into woocommerce_payment_complete, render your template, and call the same API endpoint. The returned URL is stored as order meta so it can appear on the customer account page.
<?php
// functions.php — generate order PDF on payment complete
add_action('woocommerce_payment_complete', function (int $order_id): void {
$order = wc_get_order($order_id);
$html = render_order_confirmation_template($order); // your Mustache/Twig render
$response = wp_remote_post('https://api.pdfpipe.xyz/v1/pdf', [
'headers' => [
'Authorization' => 'Bearer ' . getenv('PDFPIPE_KEY'),
'Content-Type' => 'application/json',
],
'body' => wp_json_encode([
'html' => $html,
'filename' => "order-{$order->get_order_number()}.pdf",
'store' => true,
'options' => ['format' => 'A4'],
]),
'timeout' => 30,
]);
if (is_wp_error($response)) {
return; // log and retry via Action Scheduler
}
$body = json_decode(wp_remote_retrieve_body($response), true);
// Store the URL against the order for customer account pages
$order->update_meta_data('_confirmation_pdf_url', $body['document_url']);
$order->update_meta_data('_confirmation_pdf_id', $body['document_id']);
$order->save();
});Packing slips
Packing slips need a different template (no pricing, warehouse-readable layout, 6"x4" page size) and a different trigger. They are generated at fulfillment time, not payment time, so you listen to fulfillments/create instead.
<!-- templates/packing-slip.html — minimal, warehouse-readable -->
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: monospace; color: #000; margin: 0; padding: 32px; font-size: 12px; }
.header { display: flex; justify-content: space-between; margin-bottom: 24px; }
.barcode { font-size: 32px; letter-spacing: 8px; margin: 16px 0; font-family: 'Libre Barcode 128', monospace; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 6px 8px; border: 1px solid #ccc; text-align: left; font-size: 12px; }
th { background: #f0f0f0; font-weight: 600; }
.ship-box { border: 2px solid #000; padding: 12px; margin-bottom: 20px; }
@page { margin: 0; size: 6in 4in; }
</style>
</head>
<body>
<div class="header">
<div>
<strong>{{storeName}}</strong><br>
PACKING SLIP
</div>
<div style="text-align:right">
Order #{{orderNumber}}<br>
{{orderDate}}<br>
Items: {{totalItems}}
</div>
</div>
<div class="barcode">*{{orderNumber}}*</div>
<div class="ship-box">
<strong>SHIP TO</strong><br>
{{shippingName}}<br>
{{shippingLine1}}<br>
{{#shippingLine2}}{{shippingLine2}}<br>{{/shippingLine2}}
{{shippingCity}}, {{shippingState}} {{shippingZip}}<br>
{{shippingCountry}}
</div>
<table>
<thead>
<tr><th>SKU</th><th>Product</th><th>Qty</th></tr>
</thead>
<tbody>
{{#lineItems}}
<tr>
<td>{{sku}}</td>
<td>{{productName}}{{#variantTitle}} — {{variantTitle}}{{/variantTitle}}</td>
<td>{{quantity}}</td>
</tr>
{{/lineItems}}
</tbody>
</table>
</body>
</html>The API supports custom page sizes and zero-margin output, making it practical for thermal label printers and warehouse management systems.
// Packing slips are rendered at fulfillment time, not at payment.
// Shopify webhook topic: fulfillments/create
export async function handleFulfillmentCreate(req: Request): Promise<Response> {
const rawBody = await req.text();
// ... verify HMAC as above ...
const fulfillment = JSON.parse(rawBody);
const html = Mustache.render(packingSlipTemplate, {
storeName: process.env.STORE_NAME,
orderNumber: fulfillment.order_id,
orderDate: new Date(fulfillment.created_at).toLocaleDateString("en-US"),
totalItems: fulfillment.line_items.reduce((n: number, i: FulfillmentItem) => n + i.quantity, 0),
shippingName: fulfillment.destination?.name,
shippingLine1: fulfillment.destination?.address1,
shippingLine2: fulfillment.destination?.address2 || null,
shippingCity: fulfillment.destination?.city,
shippingState: fulfillment.destination?.province_code,
shippingZip: fulfillment.destination?.zip,
shippingCountry: fulfillment.destination?.country,
lineItems: fulfillment.line_items.map((item: FulfillmentItem) => ({
sku: item.sku || "—",
productName: item.title,
variantTitle: item.variant_title !== "Default Title" ? item.variant_title : null,
quantity: item.quantity,
})),
});
// 6"x4" label size — no margins, no headers/footers
const pdfRes = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PDFPIPE_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
html,
filename: `packing-slip-${fulfillment.order_id}.pdf`,
store: true,
options: {
width: "6in",
height: "4in",
margin: { top: "0", right: "0", bottom: "0", left: "0" },
printBackground: true,
},
}),
});
const { document_url } = await pdfRes.json();
// Push URL to your warehouse management system or 3PL
await notifyWarehouse({ fulfillmentId: fulfillment.id, packingSlipUrl: document_url });
return new Response(JSON.stringify({ received: true }), { status: 200 });
}
interface FulfillmentItem {
sku: string;
title: string;
variant_title: string;
quantity: number;
}Plans
| Plan | PDFs/mo | Storage | Price |
|---|---|---|---|
| Hobby | 500 | 1 day | Free |
| Starter | 3,000 | 30 days | $19/mo |
| Growth | 15,000 | 365 days | $49/mo |
| Scale | 50,000 | 2 years | $149/mo |
| Business | 100,000 | 2 years | $499/mo |
Need more than 100,000 PDFs per month? Contact us for enterprise pricing.