PDF Webhook Payload Reference | HMAC Verification & Retry
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:
- Use the raw request body (UTF-8 encoded JSON bytes) as the message
- Use the webhook secret configured in your dashboard as the key
- 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()parsesreq.bodyinto an object, discarding the original bytes. Always useexpress.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_idfor 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
- PDF API Webhook Integration Guide — Slack and email notification setup
- PDF API Production Guide — Production best practices
- Automate Invoice PDF Generation — Invoice workflow with webhook callbacks
- API Documentation — Full API reference
- Playground — Generate test PDFs and trigger webhooks