PDFPipe

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.

HTML template (Mustache)
<!-- 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.

TypeScript
// 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
<?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.

HTML template (Mustache)
<!-- 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.

TypeScript
// 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

PlanPDFs/moStoragePrice
Hobby5001 dayFree
Starter3,00030 days$19/mo
Growth15,000365 days$49/mo
Scale50,0002 years$149/mo
Business100,0002 years$499/mo

Need more than 100,000 PDFs per month? Contact us for enterprise pricing.

Try it free →