PDFPipe

Guide

HTML to PDF in Temporal

Temporal is a workflow orchestration platform used by teams at Netflix, Snap, and HashiCorp to run long-running, durable processes as plain TypeScript code. PDF generation fits naturally as a workflow step: when an order is confirmed, generate an invoice PDF, then email it to the customer. Each step is an activity. If the PDF service returns a transient error, Temporal retries the activity automatically with exponential backoff. No custom retry logic needed.

Overview

Temporal separates orchestration from execution. A workflow is a durable function that sequences steps and stores state. An activity is a regular function that does the actual work. The PDF API call goes in the activity file because activities have full access to Node.js APIs and environment variables. Workflow code runs in a deterministic sandbox and cannot call fetch directly.

typescript
// Temporal lets you write long-running, durable workflows as plain TypeScript.
// A workflow orchestrates steps; activities do the actual work.
//
// Install the Temporal SDK:
// npm install @temporalio/workflow @temporalio/activity @temporalio/client @temporalio/worker
//
// Set your API key in .env:
// PDFPIPE_API_KEY=your_api_key_here
//
// Keep activities and workflows in separate files.
// Temporal's bundler isolates workflow code from Node.js APIs,
// so any fetch calls or env reads must live in the activity file.

Activity: generate the PDF

The generatePdfActivity function calls the PDF API and returns the stored document URL. Passing store: true is important here: it tells the API to persist the rendered document and return a stable URL. That URL can then be passed as a plain string to the next activity in the workflow, without streaming bytes between steps.

For 5xx errors, the activity throws a retryable ApplicationFailure. Temporal will catch it and retry according to the policy set on the workflow side. For 4xx errors (bad request, invalid HTML), the failure is marked non-retryable so the workflow fails fast rather than retrying a request that will never succeed:

typescript
// src/activities.ts
// Activities run inside the Worker process and have access to Node.js APIs,
// env vars, and external services. This is where the PDF API call lives.

import { ApplicationFailure } from "@temporalio/activity";

export interface GeneratePdfInput {
  orderId: string;
  html: string;
}

export interface GeneratePdfOutput {
  documentUrl: string;
}

export async function generatePdfActivity(
  input: GeneratePdfInput
): Promise<GeneratePdfOutput> {
  const apiKey = process.env.PDFPIPE_API_KEY;
  if (!apiKey) {
    throw new ApplicationFailure("PDFPIPE_API_KEY is not set", "ConfigError");
  }

  const response = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html: input.html,
      options: { format: "A4" },
      // store: true returns a persistent URL that survives across workflow steps.
      // Without it the response is a byte stream you cannot pass to the next step.
      store: true,
      filename: `invoice-${input.orderId}.pdf`,
    }),
  });

  if (!response.ok) {
    const err = await response.json().catch(() => ({}));
    const detail = (err as { detail?: string }).detail ?? "render failed";

    // Throw a non-retryable failure only for client errors (4xx).
    // For 5xx errors, let Temporal's default retry policy handle it.
    if (response.status >= 400 && response.status < 500) {
      throw new ApplicationFailure(detail, "ClientError", true);
    }

    throw new ApplicationFailure(detail, "ServerError");
  }

  const result = await response.json();
  return { documentUrl: result.document_url };
}

export async function sendInvoiceEmailActivity(input: {
  orderId: string;
  recipientEmail: string;
  documentUrl: string;
}): Promise<void> {
  // Replace with your email provider (Resend, SendGrid, etc.).
  console.log(
    `Sending invoice for order ${input.orderId} to ${input.recipientEmail}`
  );
  console.log(`PDF: ${input.documentUrl}`);
}

Workflow: orchestrate the steps

The invoiceWorkflow function sequences two activities: generatePdfActivity then sendInvoiceEmailActivity. The document URL returned by the first activity is passed directly to the second. Temporal stores the workflow state durably, so even if the Worker process restarts between the two steps, the workflow resumes from the email step with the PDF URL already resolved. The retry policy on proxyActivities applies to every activity scheduled by this workflow:

typescript
// src/workflows.ts
// Workflow code runs inside Temporal's deterministic sandbox.
// It cannot use Node.js APIs, timers, or fetch directly.
// Instead it schedules activities and awaits their results.

import { proxyActivities, sleep } from "@temporalio/workflow";
import type { generatePdfActivity, sendInvoiceEmailActivity } from "./activities";

// proxyActivities creates typed stubs for each activity function.
// The options here set the retry policy for all activities in this workflow.
const { generatePdfActivity: generatePdf, sendInvoiceEmailActivity: sendEmail } =
  proxyActivities<{
    generatePdfActivity: typeof generatePdfActivity;
    sendInvoiceEmailActivity: typeof sendInvoiceEmailActivity;
  }>({
    startToCloseTimeout: "30 seconds",
    retry: {
      // Temporal retries activities automatically on failure.
      // 5xx errors from the PDF API are retried up to maximumAttempts times
      // with exponential backoff. No extra error-handling code needed.
      maximumAttempts: 5,
      initialInterval: "2 seconds",
      backoffCoefficient: 2,
      maximumInterval: "30 seconds",
    },
  });

export interface InvoiceWorkflowInput {
  orderId: string;
  html: string;
  recipientEmail: string;
}

export async function invoiceWorkflow(
  input: InvoiceWorkflowInput
): Promise<{ documentUrl: string }> {
  // Step 1: Generate the PDF. If the PDF API returns a 5xx error,
  // Temporal will retry this activity automatically.
  const { documentUrl } = await generatePdf({
    orderId: input.orderId,
    html: input.html,
  });

  // The document URL is a durable value stored in workflow state.
  // Even if the Worker restarts between steps, Temporal replays
  // the workflow and picks up from where it left off.

  // Step 2: Email the PDF link to the customer.
  await sendEmail({
    orderId: input.orderId,
    recipientEmail: input.recipientEmail,
    documentUrl,
  });

  return { documentUrl };
}

Worker: host the runtime

The Worker process registers both the activities and the workflow bundle. It listens on a task queue and executes work dispatched by the Temporal server. Run it as a long-lived Node.js process alongside your application:

typescript
// src/worker.ts
// The Worker hosts both activities and the workflow bundle.
// Start it as a long-running Node.js process alongside your application.

import { Worker, NativeConnection } from "@temporalio/worker";
import * as activities from "./activities";

async function main() {
  const connection = await NativeConnection.connect({
    address: process.env.TEMPORAL_ADDRESS ?? "localhost:7233",
  });

  const worker = await Worker.create({
    connection,
    namespace: "default",
    taskQueue: "invoice-queue",
    // Point to the compiled workflow file.
    // Temporal bundles it separately to enforce the determinism sandbox.
    workflowsPath: require.resolve("./workflows"),
    activities,
  });

  console.log("Worker started on task queue: invoice-queue");
  await worker.run();
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

Client: start a workflow

Any process that has network access to the Temporal server can start a workflow using the client. Call client.workflow.start from an API route, a message queue consumer, or any event handler. Using the order ID as the workflow ID ensures idempotency: if the same event fires twice, the second call returns a handle to the already-running workflow rather than starting a duplicate:

typescript
// src/triggerWorkflow.ts
// Call this from an API route or queue consumer to kick off the workflow.
// The client is lightweight and does not need to run inside the Worker process.

import { Client, Connection } from "@temporalio/client";
import { invoiceWorkflow, type InvoiceWorkflowInput } from "./workflows";
import { v4 as uuidv4 } from "uuid";

async function triggerInvoiceWorkflow(
  orderId: string,
  html: string,
  recipientEmail: string
): Promise<string> {
  const connection = await Connection.connect({
    address: process.env.TEMPORAL_ADDRESS ?? "localhost:7233",
  });

  const client = new Client({ connection });

  const handle = await client.workflow.start(invoiceWorkflow, {
    taskQueue: "invoice-queue",
    // Use the orderId as the workflow ID so you can look it up later
    // and ensure only one workflow runs per order.
    workflowId: `invoice-${orderId}`,
    args: [{ orderId, html, recipientEmail } satisfies InvoiceWorkflowInput],
  });

  console.log(`Started workflow ${handle.workflowId}`);

  // Wait for the result. Remove the await if you want fire-and-forget.
  const result = await handle.result();
  return result.documentUrl;
}

// Example call from an order-confirmed event handler:
// const pdfUrl = await triggerInvoiceWorkflow(
//   order.id,
//   renderInvoiceHtml(order),
//   order.customerEmail
// );

Environment variables

Store your API key and the Temporal server address in .env. The Worker reads them at startup. In production, inject them through your infrastructure's secret manager so they are never committed to source control:

sh
# .env  (project root)
PDFPIPE_API_KEY=your_api_key_here

# Address of your Temporal server.
# For local development, start Temporal with:
#   npx @temporalio/cli server start-dev
# The default address is localhost:7233.
TEMPORAL_ADDRESS=localhost:7233

# In production, set TEMPORAL_ADDRESS to your Temporal Cloud namespace endpoint.
# Temporal Cloud also requires mTLS certificates — see the Temporal Cloud docs
# for NativeConnection options.

Getting an API key

The Hobby plan gives 500 free documents a month with no credit card required. Paid plans start at $19 and include a longer document archive, higher limits, and email support.

Full API reference on the docs page. Try it now in the live playground with no signup.

Get an API key →