PDFPipe

Guide

HTML to PDF in Azure Functions

Azure Functions is a natural fit for on-demand document generation, but running a full rendering engine inside a function is surprisingly painful. Here is a cleaner path that keeps your function thin and fast.

Why a headless browser is a poor fit

The Consumption plan caps each function instance at 1.5 GB of RAM. A Chromium binary alone is around 300 MB before it loads a single page. Under any real load the process hits the memory ceiling and the function host kills it. Cold starts compound the problem: the binary must be decompressed and initialized before the first render, adding several seconds to every cold invocation.

Beyond memory, distributing a large native binary as a function app dependency means managing architecture-specific builds (x86 vs. arm64), layer caching, and keeping the rendering engine patched for security. None of that has anything to do with what your function is actually supposed to do.

HTTP trigger: stream the PDF back to the caller

The function below accepts a JSON body, forwards it to the rendering API, and pipes the binary PDF response straight back. No binary deps, no memory spikes.

TypeScript (Azure Functions v4)
// src/functions/generatePdf.ts
import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";

app.http("generatePdf", {
  methods: ["POST"],
  authLevel: "anonymous",
  handler: async (req: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
    const body = await req.text();

    const res = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.PDFPIPE_KEY}`,
        "Content-Type": "application/json",
      },
      body,
    });

    if (!res.ok) {
      const { detail } = await res.json() as { detail: string };
      return { status: 502, body: detail };
    }

    const pdf = await res.arrayBuffer();

    return {
      status: 200,
      body: Buffer.from(pdf),
      headers: {
        "Content-Type": "application/pdf",
        "Content-Disposition": 'attachment; filename="document.pdf"',
      },
    };
  },
});

The function passes the raw request body through unchanged, so any options your caller sets (page format, margins, header/footer templates) reach the renderer without extra parsing. If the render fails, the API returns a structured error object with a detail field, which the function surfaces as a 502 rather than an opaque crash.

Async delivery: store the PDF and return a URL

For larger documents or when your caller should not wait for the render to finish, pass store: true in the request body. The API renders the document, uploads it to storage, and returns a document_url. Your function can return that URL to the caller immediately, or store it in a queue for downstream processing.

TypeScript (Azure Functions v4)
// src/functions/generatePdfAsync.ts
import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";

app.http("generatePdfAsync", {
  methods: ["POST"],
  authLevel: "anonymous",
  handler: async (req: HttpRequest, _context: InvocationContext): Promise<HttpResponseInit> => {
    const { html, options } = await req.json() as { html: string; options?: Record<string, unknown> };

    const res = 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, options, store: true }),
    });

    if (!res.ok) {
      const { detail } = await res.json() as { detail: string };
      return { status: 502, body: detail };
    }

    // { document_url: "https://..." }
    const result = await res.json();

    return {
      status: 200,
      body: JSON.stringify(result),
      headers: { "Content-Type": "application/json" },
    };
  },
});

Configuring PDFPIPE_KEY in Azure Portal

Application Settings are the Azure equivalent of environment variables. They are encrypted at rest and injected into the function host at startup.

  1. Open your Function App in the Azure Portal and navigate to Settings then Environment variables.
  2. Click Add, set the name to PDFPIPE_KEY, and paste your API key as the value.
  3. Click Apply then Save. The function app restarts and the key is available via process.env.PDFPIPE_KEY (Node.js) or os.environ["PDFPIPE_KEY"] (Python).

If you use the Azure CLI or Bicep for infrastructure-as-code, set the key with:

Azure CLI
az functionapp config appsettings set \
  --name <your-function-app> \
  --resource-group <your-rg> \
  --settings PDFPIPE_KEY=<your-api-key>

Python v2 programming model

If your function app runs Python, the v2 programming model uses decorators instead of a separate function.json. The same pattern applies: forward the body, check the status, return the binary.

Python (Azure Functions v2)
# function_app.py
import azure.functions as func
import os, requests, json

app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS)

@app.route(route="generate-pdf", methods=["POST"])
def generate_pdf(req: func.HttpRequest) -> func.HttpResponse:
    body = req.get_body()

    resp = requests.post(
        "https://api.pdfpipe.xyz/v1/pdf",
        headers={
            "Authorization": f"Bearer {os.environ['PDFPIPE_KEY']}",
            "Content-Type": "application/json",
        },
        data=body,
        timeout=30,
    )

    if not resp.ok:
        detail = resp.json().get("detail", resp.text)
        return func.HttpResponse(detail, status_code=502)

    return func.HttpResponse(
        resp.content,
        status_code=200,
        mimetype="application/pdf",
        headers={"Content-Disposition": 'attachment; filename="document.pdf"'},
    )

Add requests to your requirements.txt. The timeout=30 argument prevents the function from hanging indefinitely if the network stalls; adjust it to match your function timeout setting.

Batch rendering with Durable Functions

If you need to render dozens or hundreds of documents in a single job, Durable Functions orchestrators are a natural fit. An orchestrator fans out one activity per document and waits for all of them to complete before returning the full list of URLs. Because each activity is a separate function invocation, renders run in parallel without you managing thread pools or queues.

TypeScript (Durable Functions)
// orchestrator/index.ts  (simplified)
import * as df from "durable-functions";

df.app.orchestration("batchPdfOrchestrator", function* (context) {
  const items: string[] = context.df.getInput();
  const tasks = items.map((html) =>
    context.df.callActivity("renderSinglePdf", html)
  );
  const urls: string[] = yield context.df.Task.all(tasks);
  return urls;
});

df.app.activity("renderSinglePdf", {
  handler: async (html: string) => {
    const res = 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, store: true }),
    });
    const { document_url } = await res.json() as { document_url: string };
    return document_url;
  },
});

The activity uses store: true so each document is uploaded and a URL is returned rather than piping binary data through the orchestrator. Durable Functions replays the orchestrator on restarts, so keep the activity side-effect-free and idempotent.

Pricing

Flat monthly plans. No per-seat fees, no surprise egress charges.

PlanPriceDocuments / month
HobbyFree500
Starter$19 / mo5,000
Growth$49 / mo20,000
Scale$149 / mo50,000
Business$499 / mo500,000

PDFPipe is the API used throughout this guide. Get a key, paste it into Application Settings, and your first 500 documents are free. There is also a live playground you can try without signing up.

See pricing →