May 12, 2026

PDF Webhook Payload Reference | HMAC Verification & Retry

webhookAPIsecurityPDF generationautomation

The PDF API Webhook Integration Guide explains how to set up notifications. This article is a focused reference on payload field definitions, HMAC-SHA256 signature verification, and retry patterns — everything you need when implementing or debugging a webhook receiver.

Top-Level Payload Structure

Every webhook request shares the same top-level shape.

{
  "event": "pdf.generated",
  "timestamp": "2026-05-12T10:00:00Z",
  "webhook_id": "wh_01HZ4K9MXPQ3R8VW2N5T7BCJD",
  "api_version": "2026-03-01",
  "data": { ... }
}

Field Definitions

Field Type Description
event string Event type (see below)
timestamp string (ISO 8601) Event time (UTC)
webhook_id string Unique delivery ID — use for idempotency
api_version string API version that produced the event
data object Event-specific payload (see below)

Event Types and Payload Schemas

pdf.generated — PDF generation completed

{
  "event": "pdf.generated",
  "timestamp": "2026-05-12T10:00:00Z",
  "webhook_id": "wh_01HZ4K9MXPQ3R8VW2N5T7BCJD",
  "api_version": "2026-03-01",
  "data": {
    "id": "pdf_abc123",
    "filename": "invoice-42.pdf",
    "file_size": 48210,
    "page_count": 2,
    "download_url": "https://api.pdf.funbrew.cloud/dl/abc123?expires=1748736000&sig=...",
    "download_url_expires_at": "2026-05-13T10:00:00Z",
    "engine": "quality",
    "generation_time_ms": 1250,
    "metadata": {
      "user_ref": "invoice-42",
      "customer_id": "cust_987"
    }
  }
}
Field Type Description
id string Unique PDF ID
filename string Filename used on download
file_size integer File size in bytes
page_count integer Number of pages
download_url string Signed download URL (expires)
download_url_expires_at string (ISO 8601) Expiry time of the download URL
engine string Render engine used (quality / speed)
generation_time_ms integer Time taken to generate in milliseconds
metadata object Arbitrary key-value pairs passed with the request

pdf.failed — PDF generation failed

{
  "event": "pdf.failed",
  "timestamp": "2026-05-12T10:00:05Z",
  "webhook_id": "wh_01HZ4KA1PNBR2C8XM4S6Y0DHEV",
  "api_version": "2026-03-01",
  "data": {
    "id": "pdf_abc124",
    "filename": "report-5.pdf",
    "error_code": "TEMPLATE_RENDER_ERROR",
    "error_message": "Template variable 'customer.name' is undefined",
    "metadata": {
      "user_ref": "report-5"
    }
  }
}
error_code Description
TEMPLATE_RENDER_ERROR Undefined template variable or syntax error
RESOURCE_FETCH_ERROR Failed to fetch an external image or CSS file
TIMEOUT_ERROR Engine processing exceeded 60 seconds
INVALID_INPUT HTML input is malformed or empty
INTERNAL_ERROR Server-side error (eligible for automatic retry)

pdf.emailed — Email delivery completed

{
  "event": "pdf.emailed",
  "data": {
    "id": "pdf_abc123",
    "filename": "invoice-42.pdf",
    "email_to": "customer@example.com",
    "email_subject": "Your invoice is ready",
    "email_sent_at": "2026-05-12T10:00:10Z"
  }
}

If you use automatic email delivery as part of your invoice automation workflow, use this event to update your CRM's send-status field.

batch.completed — Batch processing completed

{
  "event": "batch.completed",
  "data": {
    "batch_id": "batch_xyz789",
    "total": 150,
    "succeeded": 148,
    "failed": 2,
    "completed_at": "2026-05-12T10:05:00Z",
    "failed_ids": ["pdf_err1", "pdf_err2"]
  }
}
Field Description
total Total PDFs in the batch
succeeded Number of successful generations
failed Number of failed generations
failed_ids IDs of failed PDFs

HMAC-SHA256 Signature Verification

How Signatures Work

The request header X-Funbrew-Signature contains an HMAC-SHA256 hash of the raw request body.

X-Funbrew-Signature: sha256=<hex-encoded HMAC>

The signature is computed as follows:

  1. Use the raw request body (UTF-8 encoded JSON bytes) as the message
  2. Use the webhook secret configured in your dashboard as the key
  3. Compute HMAC-SHA256 and encode as a lowercase hex string

Verification by Language

Node.js

const crypto = require('crypto');

function verifyWebhookSignature(rawBody, signature, secret) {
  // rawBody must be the raw Buffer — do NOT re-serialize the parsed object
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('hex');

  // Use timingSafeEqual to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// Use express.raw() so req.body stays as a Buffer
app.use('/webhooks/funbrew-pdf', express.raw({ type: 'application/json' }));

app.post('/webhooks/funbrew-pdf', (req, res) => {
  const sig = req.headers['x-funbrew-signature'] || '';
  if (!verifyWebhookSignature(req.body, sig, process.env.WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  const payload = JSON.parse(req.body.toString());
  // ...handle event
  res.json({ received: true });
});

Important: Using express.json() parses req.body into an object, discarding the original bytes. Always use express.raw() on webhook routes so you can verify the signature against the unmodified body.

Python (FastAPI)

import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException

app = FastAPI()

def verify_signature(body: bytes, signature: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode("utf-8"),
        body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature)  # constant-time comparison

@app.post("/webhooks/funbrew-pdf")
async def handle_webhook(request: Request):
    body = await request.body()  # raw bytes before JSON parsing
    sig = request.headers.get("x-funbrew-signature", "")
    if not verify_signature(body, sig, os.environ["WEBHOOK_SECRET"]):
        raise HTTPException(status_code=401, detail="Invalid signature")
    payload = await request.json()
    # ...handle event
    return {"received": True}

PHP (Laravel)

class WebhookController extends Controller
{
    public function handle(Request $request)
    {
        $signature = $request->header('X-Funbrew-Signature', '');
        $expected = 'sha256=' . hash_hmac(
            'sha256',
            $request->getContent(), // raw request body string
            config('services.funbrew.webhook_secret')
        );

        if (!hash_equals($expected, $signature)) { // constant-time comparison
            abort(401, 'Invalid signature');
        }

        $payload = $request->json()->all();
        // ...handle event
        return response()->json(['received' => true]);
    }
}

Timestamp Validation (Replay Attack Prevention)

function isTimestampValid(timestamp, toleranceMs = 5 * 60 * 1000) {
  const eventTime = new Date(timestamp).getTime();
  return Math.abs(Date.now() - eventTime) < toleranceMs;
}

app.post('/webhooks/funbrew-pdf', (req, res) => {
  const payload = JSON.parse(req.body.toString());
  if (!isTimestampValid(payload.timestamp)) {
    return res.status(401).json({ error: 'Timestamp out of range' });
  }
  // ...
});

Retry Patterns

FUNBREW PDF Retry Schedule

When your endpoint fails to return a 2xx within 5 seconds, or returns a connection error, FUNBREW PDF retries on the following schedule:

Attempt Delay
1st retry 30 seconds
2nd retry 5 minutes
3rd retry 30 minutes
4th retry 2 hours
5th retry (final) 6 hours

After all 5 retries fail, the event is logged in the "Webhook Errors" section of your dashboard.

Idempotent Processing with webhook_id

Because retries mean the same event can arrive more than once, use webhook_id to deduplicate.

const { createClient } = require('redis');
const redis = createClient({ url: process.env.REDIS_URL });

async function processOnce(webhookId, handler) {
  const key = `webhook:processed:${webhookId}`;
  // SET NX EX: set only if absent, expire after 24 hours
  const isNew = await redis.set(key, '1', { NX: true, EX: 86400 });
  if (!isNew) {
    console.log(`Duplicate webhook ignored: ${webhookId}`);
    return;
  }
  await handler();
}

app.post('/webhooks/funbrew-pdf', async (req, res) => {
  // After signature verification...
  res.json({ received: true }); // Respond first

  const { webhook_id, event, data } = JSON.parse(req.body.toString());
  setImmediate(() =>
    processOnce(webhook_id, () => handleEvent(event, data))
  );
});

Manual Re-delivery

You can trigger a re-delivery from the dashboard, or via API:

# Re-deliver a specific webhook event
curl -X POST https://api.pdf.funbrew.cloud/v1/webhooks/deliveries/wh_01HZ4K9MXPQ3R8VW2N5T7BCJD/retry \
  -H "Authorization: Bearer $API_KEY"

Payload Limits

Item Limit
Max payload size 1 MB
download_url validity 24 hours
metadata max keys 20
metadata value max length 256 characters

Testing Locally

# Expose your local server with ngrok
ngrok http 3000

# Send a test payload with a valid signature
SECRET="your_secret"
BODY='{"event":"pdf.generated","timestamp":"2026-05-12T10:00:00Z","webhook_id":"wh_test","api_version":"2026-03-01","data":{"id":"pdf_test"}}'
SIG="sha256=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | cut -d' ' -f2)"

curl -X POST http://localhost:3000/webhooks/funbrew-pdf \
  -H "Content-Type: application/json" \
  -H "X-Funbrew-Signature: $SIG" \
  -d "$BODY"

Generate a test PDF from the Playground to receive a live payload at your ngrok URL. Register the ngrok URL in your dashboard webhook settings before testing.

Summary

Key points for webhook payload handling:

  • Use webhook_id for idempotency: skip processing if you've already seen this ID
  • Verify against the raw body: compute HMAC before JSON parsing, never re-serialize
  • Validate the timestamp: reject events older than 5 minutes to block replay attacks
  • Return 200 within 5 seconds: enqueue heavy work before responding
  • Up to 5 automatic retries: final failures appear in the dashboard webhook error log

For full integration examples, see the PDF API Webhook Integration Guide. For production deployment best practices, see the PDF API Production Guide. For invoice automation with webhooks, see Automate Invoice PDF Generation.

Related

Powered by FUNBREW PDF