PDFPipe

Guide

HTML to PDF in Supabase Edge Functions

Supabase Edge Functions run on Deno at the edge, close to your users. They are a natural place to react to events: a new row in the invoices table, an incoming Stripe webhook, or a direct API call from your frontend. Because you cannot run a full rendering engine inside an Edge Function, offloading to a PDF API is the correct architecture. The function stays lightweight, the PDF job runs elsewhere, and the resulting URL lands back in your database.

Basic Edge Function

A minimal function that accepts a JSON body with html and an optional filename, calls the PDF API with store: true, and returns the stored document URL to the caller. The API key comes from Deno.env.get, keeping it out of your source code:

typescript (deno)
// supabase/functions/generate-pdf/index.ts
import { serve } from "https://deno.land/[email protected]/http/server.ts";

serve(async (req) => {
  const { html, filename = "document.pdf" } = await req.json();

  const res = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${Deno.env.get("PDFPIPE_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      filename,
      store: true,
    }),
  });

  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    return new Response(
      JSON.stringify({ error: (err as any).detail ?? "render failed" }),
      { status: 502, headers: { "Content-Type": "application/json" } }
    );
  }

  // Drain the body. The URL comes back in response headers.
  await res.arrayBuffer();

  return new Response(
    JSON.stringify({
      document_url: res.headers.get("X-PDFPipe-Document-Url"),
      document_id:  res.headers.get("X-PDFPipe-Document-Id"),
      expires_at:   res.headers.get("X-PDFPipe-Document-Expires"),
    }),
    { headers: { "Content-Type": "application/json" } }
  );
});

Database Webhook trigger

Supabase Database Webhooks fire an HTTP request to any URL when a row changes. Wire an INSERT event on the invoices table to this function. On each new invoice, the function builds HTML from the record, generates the PDF, then writes the URL back to the same row using the Supabase Admin client:

typescript (deno)
// supabase/functions/invoice-pdf/index.ts
//
// Wire up in the Supabase dashboard:
//   Database -> Webhooks -> New webhook
//   Table: invoices, Event: INSERT
//   URL: https://<project-ref>.supabase.co/functions/v1/invoice-pdf
//
import { serve } from "https://deno.land/[email protected]/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);

function buildInvoiceHtml(record: Record<string, unknown>): string {
  return `
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8" />
        <style>
          body { font-family: sans-serif; padding: 40px; color: #1d1812; }
          h1   { font-size: 28px; margin-bottom: 8px; }
          .row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #e5e0d8; }
        </style>
      </head>
      <body>
        <h1>Invoice #${record.invoice_number}</h1>
        <p>Customer: ${record.customer_name}</p>
        <div class="row"><span>Amount</span><span>${record.currency} ${record.amount}</span></div>
        <div class="row"><span>Due date</span><span>${record.due_date}</span></div>
      </body>
    </html>
  `;
}

serve(async (req) => {
  const payload = await req.json();

  // Supabase Database Webhooks send: { type, table, record, old_record }
  if (payload.type !== "INSERT") {
    return new Response("ignored", { status: 200 });
  }

  const record = payload.record as Record<string, unknown>;
  const html = buildInvoiceHtml(record);

  const res = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${Deno.env.get("PDFPIPE_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      filename: `invoice-${record.invoice_number}.pdf`,
      store: true,
    }),
  });

  if (!res.ok) {
    console.error("PDF render failed", await res.text());
    return new Response("render error", { status: 502 });
  }

  await res.arrayBuffer();
  const documentUrl = res.headers.get("X-PDFPipe-Document-Url");

  // Write the URL back to the row that triggered this function.
  const { error } = await supabase
    .from("invoices")
    .update({ pdf_url: documentUrl })
    .eq("id", record.id);

  if (error) {
    console.error("DB update failed", error.message);
    return new Response("db error", { status: 500 });
  }

  return new Response(JSON.stringify({ document_url: documentUrl }), {
    headers: { "Content-Type": "application/json" },
  });
});

Configure the webhook in the Supabase dashboard under Database → Webhooks. Set the target URL to https://<project-ref>.supabase.co/functions/v1/invoice-pdf and select the INSERT event on the invoices table.

Stripe webhook trigger

The same pattern works for any external webhook provider. This example listens for invoice.paid from Stripe, generates a receipt PDF, and stores the URL in a payments table:

typescript (deno)
// supabase/functions/stripe-invoice-pdf/index.ts
//
// Point your Stripe webhook to:
//   https://<project-ref>.supabase.co/functions/v1/stripe-invoice-pdf
// Events: invoice.paid
//
import { serve } from "https://deno.land/[email protected]/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);

serve(async (req) => {
  const event = await req.json();
  if (event.type !== "invoice.paid") {
    return new Response("ignored", { status: 200 });
  }

  const invoice = event.data.object;
  const html = `
    <!DOCTYPE html>
    <html>
      <head><meta charset="utf-8" /></head>
      <body style="font-family:sans-serif;padding:40px;">
        <h1>Receipt</h1>
        <p>Customer: ${invoice.customer_name ?? invoice.customer_email}</p>
        <p>Amount paid: ${(invoice.amount_paid / 100).toFixed(2)} ${invoice.currency.toUpperCase()}</p>
        <p>Invoice ID: ${invoice.id}</p>
      </body>
    </html>
  `;

  const res = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${Deno.env.get("PDFPIPE_API_KEY")}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      filename: `receipt-${invoice.id}.pdf`,
      store: true,
    }),
  });

  if (!res.ok) return new Response("render error", { status: 502 });

  await res.arrayBuffer();
  const documentUrl = res.headers.get("X-PDFPipe-Document-Url");

  await supabase
    .from("payments")
    .update({ receipt_pdf_url: documentUrl })
    .eq("stripe_invoice_id", invoice.id);

  return new Response(JSON.stringify({ document_url: documentUrl }), {
    headers: { "Content-Type": "application/json" },
  });
});

Point your Stripe webhook to the deployed function URL and select the invoice.paid event type in the Stripe dashboard. Add Stripe signature verification before going to production.

Environment setup

Store your API key with the Supabase CLI so it is injected at runtime and never committed to source. The Supabase service role key is available automatically inside every Edge Function as SUPABASE_SERVICE_ROLE_KEY:

sh
# 1. Store your API key as a Supabase secret (never commit it).
supabase secrets set PDFPIPE_API_KEY=your_api_key_here

# 2. Supabase service role key is injected automatically into every
#    Edge Function at runtime. No manual secret needed for it.

# 3. If you use an import map, create supabase/functions/import_map.json:
{
  "imports": {
    "std/": "https://deno.land/[email protected]/",
    "@supabase/supabase-js": "https://esm.sh/@supabase/supabase-js@2"
  }
}

# 4. Deploy the function.
supabase functions deploy invoice-pdf

# 5. (Optional) Test locally.
supabase functions serve invoice-pdf --env-file .env.local

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 →