Webhooks
Receive notifications when jobs complete, fail, or change status.
Overview
Configure webhooks to receive real-time notifications about your PDF generation jobs. The webhook system supports:
- Signed deliveries - Every request carries a
PodPDF-Signatureheader so you can prove it came from PodPDF (how to verify) - Multiple webhooks per user (plan-based limits) - Create different webhooks for different purposes (production, staging, monitoring, etc.)
- Event-based subscriptions - Subscribe only to events you care about (e.g., only
job.completedorjob.failed) - Delivery tracking - View delivery history, success/failure counts, and statistics
- Activation control - Enable/disable webhooks as needed (
is_activefield) - Status monitoring - Track last triggered time, last success, and last failure for each webhook
Multiple Webhooks Support
You can create multiple webhooks for different purposes:
- Production webhook - For live job notifications
- Staging webhook - For testing and development
- Monitoring webhook - For alerts and logging
- Different events - Separate webhooks for different event types
Each webhook can subscribe to different events and have its own URL, allowing you to route notifications to different endpoints based on your needs.
Plan-Based Limits
Maximum webhooks per user is determined by their plan's max_webhooks field. The standard paid plan allows 5 webhooks; plans that do not set the field also get 5.
If you reach the limit, creating a new webhook returns 403 Forbidden with error code WEBHOOK_LIMIT_EXCEEDED, including details:
plan_id- Your current plan IDplan_type- Plan typecurrent_count- Number of webhooks you currently havemax_allowed- Maximum webhooks allowed for your planupgrade_required- Whether you need to upgrade to get more webhooks
Event Types
You can subscribe to the following events:
| Event | Description | When Triggered |
|---|---|---|
job.completed | Job successfully completed | Long job finishes PDF generation, or a merge/split job finishes |
job.failed | Job failed during processing | PDF generation error, Chromium crash, or a merge/split job fails |
job.queued | Job queued for processing | Long job submitted and queued |
job.processing | Job started processing | Long job extracted from queue and processing |
bulk.job.completed | Bulk job converted every file | Bulk job finishes with no failures |
bulk.job.partial | Bulk job converted some files | Bulk job finishes with failed or skipped files |
bulk.job.failed | Bulk job produced nothing | Bulk job was rejected, or no file converted |
bulk.job.cancelled | Bulk job was cancelled | POST /jobs/{job_id}/cancel stopped it. success_count and s3_url cover any PDFs made before the cancel |
mailmerge.campaign.completed | Every valid row was sent | A mail merge campaign finishes with every email accepted |
mailmerge.campaign.partial | Some rows were not sent | A campaign finishes with failed, rejected or unconfirmed rows, or stops early (error_code says why) |
mailmerge.campaign.failed | Nothing was sent | Every row failed, or the campaign could not start |
mailmerge.campaign.cancelled | Campaign was cancelled | POST /jobs/{job_id}/cancel stopped it. sent_count covers emails sent before the cancel |
job.timeout | Reserved | Subscribable, but not currently delivered |
Mail merge payloads carry campaign_id, campaign_name, the counts (sent_count, failed_count, rejected_count, unconfirmed_count, skipped_count), error_code and a one-hour report_url to the results CSV.
Merge and split jobs use job.completed and job.failed with job_type: "pdfop" in the payload, plus operation, input_pages, output_count, warnings and files_url on completion.
Quick Start
1. Create a Webhook
curl -X POST https://api.podpdf.com/accounts/me/webhooks \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Production Webhook",
"url": "https://api.example.com/webhooks/podpdf",
"events": ["job.completed", "job.failed"],
"is_active": true
}'
Response (201 Created):
{
"webhook_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"name": "Production Webhook",
"url": "https://api.example.com/webhooks/podpdf",
"events": ["job.completed", "job.failed"],
"is_active": true,
"signing_enabled": true,
"secret": "whsec_4f1c9a7e2b8d6035e1a4c7f9b2d8e6a1c3f5b7d9e2a4c6f8b1d3e5a7c9f2b4d6",
"created_at": "2025-12-24T10:00:00Z",
"updated_at": "2025-12-24T10:00:00Z",
"success_count": 0,
"failure_count": 0
}
secret is returned only in this response and when you rotate it. No other endpoint returns it. Store it where your webhook receiver can read it, such as an environment variable.
Webhook Status Fields:
is_active(boolean) - Whether webhook is active. Inactive webhooks are not called.success_count(number) - Total successful deliveries (starts at 0)failure_count(number) - Total failed deliveries (starts at 0)last_triggered_at(string|null) - ISO 8601 timestamp when webhook was last triggered (null if never triggered)last_success_at(string|null) - ISO 8601 timestamp of last successful delivery (null if no successes)last_failure_at(string|null) - ISO 8601 timestamp of last failed delivery (null if no failures)
2. Receive Webhook Notifications
When a job completes, you'll receive a POST request to your webhook URL:
Headers:
Content-Type: application/json
User-Agent: PodPDF-Webhook/1.0
PodPDF-Signature: t=1766313135,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
X-Webhook-Event: job.completed
X-Webhook-Id: 01ARZ3NDEKTSV4RRFFQ69G5FAV
X-Webhook-Delivery-Id: 01ARZ3NDEKTSV4RRFFQ69G5FAY
X-Webhook-Timestamp: 2025-12-21T10:32:15Z
Payload (job.completed):
{
"event": "job.completed",
"job_id": "9f0a4b78-2c0c-4d14-9b8b-123456789abc",
"status": "completed",
"job_type": "long",
"mode": "html",
"pages": 12,
"truncated": false,
"s3_url": "https://s3.amazonaws.com/podpdf-dev-pdfs/9f0a4b78-2c0c-4d14-9b8b-123456789abc.pdf?X-Amz-Signature=...",
"s3_url_expires_at": "2025-12-21T11:32:15Z",
"created_at": "2025-12-21T10:30:00Z",
"completed_at": "2025-12-21T10:32:15Z",
"timestamp": "2025-12-21T10:32:15Z"
}
3. Verify and Respond
Verify the signature first, then return 200 OK quickly and do any slow work afterwards. A complete receiver is in Examples.
Verifying Signatures
Anyone who learns your webhook URL can send it a request. The PodPDF-Signature header lets you reject any request PodPDF didn't send, and any real request someone captured and replays later.
How it works
PodPDF-Signature: t=1766313135,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
t— when the request was signed, in Unix secondsv1— a hex HMAC-SHA256 of the string<t>.<raw request body>, keyed with your webhook secret
To verify a delivery:
- Read the raw request body, before any JSON parsing
- Parse
tand everyv1from the header - Reject the request if
tis more than 5 minutes from your current time - Compute HMAC-SHA256 of
`${t}.${rawBody}`with your secret - Compare it to each
v1using a constant-time comparison; accept if any matches
Each retry is re-signed with a new t, so a delivery that takes all four attempts still verifies.
Node.js
const crypto = require('node:crypto');
function verifyPodPdfWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
if (!signatureHeader || !secret) return false;
let timestamp;
const signatures = [];
for (const part of signatureHeader.split(',')) {
const [key, value] = part.split('=');
if (key === 't') timestamp = Number(value);
if (key === 'v1') signatures.push(value);
}
if (!Number.isInteger(timestamp) || signatures.length === 0) return false;
// Reject old or replayed deliveries
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest();
// During a secret rotation there is one v1 per active secret; any match is enough
return signatures.some((signature) => {
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
}
Python
import hashlib
import hmac
import time
def verify_podpdf_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool:
if not signature_header or not secret:
return False
timestamp = None
signatures = []
for part in signature_header.split(","):
key, _, value = part.partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1":
signatures.append(value)
if timestamp is None or not signatures:
return False
# Reject old or replayed deliveries
if abs(time.time() - timestamp) > tolerance_seconds:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
# During a secret rotation there is one v1 per active secret; any match is enough
return any(hmac.compare_digest(expected, signature) for signature in signatures)
Common mistakes
Most frameworks parse JSON before your handler runs. If you verify JSON.stringify(req.body) instead of the bytes that arrived, whitespace and escaping can differ and every valid signature fails. In Express use express.raw({ type: 'application/json' }) on the webhook route; in Flask use request.get_data().
- Comparing with
==leaks timing information. Usecrypto.timingSafeEqualorhmac.compare_digest. - Skipping the timestamp check lets anyone replay a captured delivery forever.
- Clock drift — the 5-minute window tolerates normal skew, but keep your server's clock synced with NTP.
Rotating your secret
Rotate with POST /accounts/me/webhooks/{webhook_id}/rotate-secret or from the dashboard.
By default the old secret keeps signing for 24 hours: during that window every delivery carries two v1 values, one per secret, and the code above accepts either. Deploy the new secret to your receiver at any point in that window and nothing is rejected.
If a secret has leaked, rotate with grace_period_seconds: 0 to stop signing with the old one immediately.
If you can't verify signatures
Some no-code tools can't compute an HMAC on an incoming request. In that case, treat the webhook as a notification only: take the job_id from it and call GET /jobs/{job_id} with your API key. That response comes from PodPDF over an authenticated connection, so its status and download link can be trusted even if the webhook was forged.
API Endpoints
Create Webhook
POST /accounts/me/webhooks
Create a new webhook configuration.
Request:
{
"name": "Production Webhook",
"url": "https://api.example.com/webhooks/podpdf",
"events": ["job.completed", "job.failed"],
"is_active": true
}
Fields:
name(string, optional) - Descriptive name for the webhookurl(string, required) - Publicly reachable HTTPS URL for webhook endpoint (1-2048 characters). See URL requirements.events(array, optional) - Event types to subscribe to. Default:["job.completed"]- Valid values:
job.completed,job.failed,job.queued,job.processing,bulk.job.completed,bulk.job.partial,bulk.job.failed,bulk.job.cancelled,mailmerge.campaign.completed,mailmerge.campaign.partial,mailmerge.campaign.failed,mailmerge.campaign.cancelled,job.timeout(reserved, not delivered)
- Valid values:
is_active(boolean, optional) - Whether webhook is active (default:true)
URL requirements
Webhooks are delivered from PodPDF's servers, so the URL must point at the public internet. Private and internal addresses are rejected with 400 INVALID_WEBHOOK_URL:
- Non-HTTPS URLs
localhostand hostnames ending in.localhost,.localor.internal- Private, loopback, link-local and reserved IP addresses, including
127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16(cloud metadata),100.64.0.0/10,0.0.0.0, and IPv6::1,fc00::/7,fe80::/10and IPv4-mapped equivalents
Hostnames are also resolved at delivery time. If a hostname resolves to a private or reserved address when a delivery is attempted, PodPDF does not connect: the delivery is recorded in delivery history with status failed and an error_message starting with Delivery blocked:, and it is not retried.
To test against a local server, expose it through a public HTTPS tunnel. Public endpoints such as AWS Lambda function URLs (https://<id>.lambda-url.<region>.on.aws/) work as usual.
Response: 201 Created with the webhook, including its signing secret. This is the only time the secret is returned other than on rotation.
List Webhooks
GET /accounts/me/webhooks
List all webhooks for your account. Returns webhooks with full status information.
Query Parameters:
is_active(boolean, optional) - Filter by active status (trueorfalse)event(string, optional) - Filter webhooks that subscribe to this event type- Valid values:
job.completed,job.failed,job.queued,job.processing,bulk.job.completed,bulk.job.partial,bulk.job.failed,bulk.job.cancelled,mailmerge.campaign.completed,mailmerge.campaign.partial,mailmerge.campaign.failed,mailmerge.campaign.cancelled,job.timeout(reserved, not delivered)
- Valid values:
limit(number, optional) - Maximum results (default: 50, max: 100)next_token(string, optional) - Pagination token from previous response
Response:
{
"webhooks": [
{
"webhook_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"name": "Production Webhook",
"url": "https://api.example.com/webhooks/podpdf",
"events": ["job.completed", "job.failed"],
"is_active": true,
"signing_enabled": true,
"created_at": "2025-12-24T10:00:00Z",
"updated_at": "2025-12-24T10:00:00Z",
"last_triggered_at": "2025-12-24T15:30:00Z",
"success_count": 150,
"failure_count": 2,
"last_success_at": "2025-12-24T15:30:00Z",
"last_failure_at": "2025-12-24T14:20:00Z"
}
],
"count": 1,
"next_token": null
}
signing_enabled is true when the webhook has a secret. List, get and update responses never include the secret itself.
Get Webhook
GET /accounts/me/webhooks/{webhook_id}
Get details of a specific webhook, including all status fields.
Response:
{
"webhook": {
"webhook_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"name": "Production Webhook",
"url": "https://api.example.com/webhooks/podpdf",
"events": ["job.completed", "job.failed"],
"is_active": true,
"signing_enabled": true,
"previous_secret_expires_at": "2025-12-25T15:30:00Z",
"created_at": "2025-12-24T10:00:00Z",
"updated_at": "2025-12-24T10:00:00Z",
"last_triggered_at": "2025-12-24T15:30:00Z",
"success_count": 150,
"failure_count": 2,
"last_success_at": "2025-12-24T15:30:00Z",
"last_failure_at": "2025-12-24T14:20:00Z"
}
}
previous_secret_expires_at appears only while a rotated-out secret is still signing deliveries.
Update Webhook
PUT /accounts/me/webhooks/{webhook_id}
Update an existing webhook configuration. All fields are optional - only provided fields are updated.
Request:
{
"name": "Updated Webhook",
"url": "https://api.example.com/webhooks/podpdf-v2",
"events": ["job.completed"],
"is_active": true
}
Fields:
name(string, optional) - Update webhook nameurl(string, optional) - Update webhook URL (must be HTTPS, 1-2048 characters, and not a private or internal address; see URL requirements)events(array, optional) - Update subscribed eventsis_active(boolean, optional) - Update active status. Set tofalseto temporarily disable webhook without deleting it.
Response:
{
"webhook_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"name": "Updated Webhook",
"url": "https://api.example.com/webhooks/podpdf-v2",
"events": ["job.completed"],
"is_active": true,
"created_at": "2025-12-24T10:00:00Z",
"updated_at": "2025-12-24T16:00:00Z",
"success_count": 150,
"failure_count": 2
}
Set is_active: false to temporarily disable a webhook. Inactive webhooks are not called, but their configuration and statistics are preserved. You can re-enable them later by setting is_active: true.
Rotate Signing Secret
POST /accounts/me/webhooks/{webhook_id}/rotate-secret
Issue a new signing secret. See Rotating your secret for how to switch without dropping deliveries.
Request (optional body):
{
"grace_period_seconds": 86400
}
Fields:
grace_period_seconds(integer, optional) - How long the old secret keeps signing deliveries, from0to604800(7 days). Default:86400(24 hours). Use0to revoke the old secret immediately.
Response (200 OK):
{
"webhook_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV",
"name": "Production Webhook",
"url": "https://api.example.com/webhooks/podpdf",
"events": ["job.completed", "job.failed"],
"is_active": true,
"signing_enabled": true,
"secret": "whsec_9b2d4f6a8c1e3f5b7d9a2c4e6f8b1d3a5c7e9f2b4d6a8c1e3f5b7d9a2c4e6f8b",
"previous_secret_expires_at": "2025-12-25T10:00:00Z",
"created_at": "2025-12-24T10:00:00Z",
"updated_at": "2025-12-24T10:00:00Z"
}
The new secret is returned only in this response. Rotating a webhook created before signing was introduced turns signing on for it.
Delete Webhook
DELETE /accounts/me/webhooks/{webhook_id}
Delete a webhook configuration.
Get Webhook History
GET /accounts/me/webhooks/{webhook_id}/history
Get delivery history for a webhook. History records are kept indefinitely.
Query Parameters:
status(string, optional) - Filter by delivery status- Valid values:
success,failed,timeout
- Valid values:
event_type(string, optional) - Filter by event type- Valid values:
job.completed,job.failed,job.queued,job.processing,bulk.job.completed,bulk.job.partial,bulk.job.failed,bulk.job.cancelled,mailmerge.campaign.completed,mailmerge.campaign.partial,mailmerge.campaign.failed,mailmerge.campaign.cancelled,job.timeout(reserved, not delivered)
- Valid values:
limit(number, optional) - Maximum results (default: 50, max: 100)next_token(string, optional) - Pagination token from previous response
Response:
{
"history": [
{
"delivery_id": "01ARZ3NDEKTSV4RRFFQ69G5FAY",
"job_id": "9f0a4b78-2c0c-4d14-9b8b-123456789abc",
"event_type": "job.completed",
"status": "success",
"status_code": 200,
"retry_count": 0,
"delivered_at": "2025-12-24T15:30:00Z",
"duration_ms": 245,
"payload_size_bytes": 1024
},
{
"delivery_id": "01ARZ3NDEKTSV4RRFFQ69G5FAZ",
"job_id": "8e1b5c89-3d1d-5e25-ac9c-234567890def",
"event_type": "job.completed",
"status": "failed",
"status_code": 500,
"error_message": "HTTP 500",
"retry_count": 3,
"delivered_at": "2025-12-24T14:20:00Z",
"duration_ms": 7500,
"payload_size_bytes": 1024
}
],
"count": 2,
"next_token": null
}
Delivery History Fields:
delivery_id(string) - Unique delivery identifier (ULID) - use for idempotencyjob_id(string) - Job ID that triggered this webhookevent_type(string) - Event type that triggered webhookstatus(string) - Delivery status:success,failed, ortimeoutstatus_code(number, optional) - HTTP status code from webhook endpointerror_message(string, optional) - Error message if delivery failedretry_count(number) - Number of retry attempts (0-3)delivered_at(string) - ISO 8601 timestamp when delivery completedduration_ms(number) - Total delivery duration in millisecondspayload_size_bytes(number) - Size of webhook payload in bytes
Webhook Payloads
job.completed
Triggered when a long job successfully completes.
{
"event": "job.completed",
"job_id": "9f0a4b78-2c0c-4d14-9b8b-123456789abc",
"status": "completed",
"job_type": "long",
"mode": "html",
"pages": 12,
"truncated": false,
"s3_url": "https://s3.amazonaws.com/podpdf-dev-pdfs/9f0a4b78-2c0c-4d14-9b8b-123456789abc.pdf?X-Amz-Signature=...",
"s3_url_expires_at": "2025-12-21T11:32:15Z",
"created_at": "2025-12-21T10:30:00Z",
"completed_at": "2025-12-21T10:32:15Z",
"timestamp": "2025-12-21T10:32:15Z"
}
job.failed
Triggered when a job fails during processing.
{
"event": "job.failed",
"job_id": "9f0a4b78-2c0c-4d14-9b8b-123456789abc",
"status": "failed",
"job_type": "long",
"mode": "html",
"error_message": "PDF generation failed: Chromium process crashed",
"created_at": "2025-12-21T10:30:00Z",
"failed_at": "2025-12-21T10:32:15Z",
"timestamp": "2025-12-21T10:32:15Z"
}
job.timeout (reserved)
This event can be subscribed to, and the payload below is the shape it would take, but nothing dispatches it today. A QuickJob that exceeds the 30-second limit returns 408 QUICKJOB_TIMEOUT on the request itself. Do not build a flow that waits for this callback.
{
"event": "job.timeout",
"job_id": "9f0a4b78-2c0c-4d14-9b8b-123456789abc",
"status": "timeout",
"job_type": "quick",
"mode": "html",
"timeout_seconds": 30,
"created_at": "2025-12-21T10:30:00Z",
"timeout_at": "2025-12-21T10:30:30Z",
"timestamp": "2025-12-21T10:30:30Z"
}
job.queued
Triggered when a long job is queued for processing.
{
"event": "job.queued",
"job_id": "9f0a4b78-2c0c-4d14-9b8b-123456789abc",
"status": "queued",
"job_type": "long",
"mode": "html",
"created_at": "2025-12-21T10:30:00Z",
"timestamp": "2025-12-21T10:30:00Z"
}
job.processing
Triggered when a long job starts processing.
{
"event": "job.processing",
"job_id": "9f0a4b78-2c0c-4d14-9b8b-123456789abc",
"status": "processing",
"job_type": "long",
"mode": "html",
"created_at": "2025-12-21T10:30:00Z",
"started_at": "2025-12-21T10:30:05Z",
"timestamp": "2025-12-21T10:30:05Z"
}
Webhook Delivery
Retry Logic
- 3 retries with exponential backoff (1s, 2s, 4s)
- Retries on:
- Network errors
- Timeout (10 seconds)
- HTTP 5xx errors
- HTTP 429 (Too Many Requests)
- Does NOT retry on:
- HTTP 2xx (success)
- HTTP 4xx (client errors, except 429)
Delivery Guarantees
- At-least-once delivery: Webhooks may be delivered multiple times
- Best-effort delivery: Failed webhooks are retried, but delivery is not guaranteed if all retries fail
- Ordering: Webhooks are delivered in event order, but delivery order is not guaranteed across different webhooks
- Idempotency: Use
delivery_idfromX-Webhook-Delivery-Idheader to deduplicate
Webhook Receiver Best Practices
- Verify the signature - Reject any request whose
PodPDF-Signaturedoesn't verify, with401. See Verifying Signatures - Use delivery_id for idempotency - Store
X-Webhook-Delivery-Idto prevent duplicate processing; retries of one delivery share the same ID - Return 200 OK quickly - Process webhook asynchronously if needed
- Validate payload structure - Check required fields and types
- Handle all event types - Even if you only subscribe to some events
- Log all deliveries - For debugging and monitoring
A 401 is not retried, so a delivery rejected for a bad signature is not sent again.
Webhook Status Fields
Each webhook includes status tracking fields:
| Field | Type | Description |
|---|---|---|
is_active | boolean | Whether webhook is active. Inactive webhooks are not called. |
success_count | number | Total successful deliveries (starts at 0, increments on each success) |
failure_count | number | Total failed deliveries (starts at 0, increments on each failure after all retries) |
last_triggered_at | string|null | ISO 8601 timestamp when webhook was last triggered (null if never triggered) |
last_success_at | string|null | ISO 8601 timestamp of last successful delivery (null if no successes) |
last_failure_at | string|null | ISO 8601 timestamp of last failed delivery (null if no failures) |
Delivery Status
Webhook delivery history includes status information:
| Status | Description |
|---|---|
success | Webhook was successfully delivered (HTTP 2xx response) |
failed | Webhook delivery failed after all retries (HTTP 4xx/5xx, network error, timeout) |
timeout | Webhook delivery timed out (10 second timeout) |
Error Responses
| Status | Error Code | Description |
|---|---|---|
400 Bad Request | INVALID_WEBHOOK_URL | URL must be HTTPS (1-2048 characters) and must not point at a private or internal address (URL requirements) |
400 Bad Request | INVALID_EVENTS | Invalid event type or empty events array |
400 Bad Request | INVALID_PARAMETER | grace_period_seconds is not a whole number from 0 to 604800, or the body is not valid JSON |
401 Unauthorized | - | Missing or invalid JWT token |
403 Forbidden | ACCOUNT_NOT_FOUND | User account not found |
403 Forbidden | WEBHOOK_LIMIT_EXCEEDED | Maximum webhooks reached for plan (includes plan details) |
403 Forbidden | WEBHOOK_ACCESS_DENIED | Webhook belongs to different user |
404 Not Found | WEBHOOK_NOT_FOUND | Webhook not found |
500 Internal Server Error | - | Server-side failure |
Examples
JavaScript (Express.js)
Uses verifyPodPdfWebhook from Verifying Signatures.
const express = require('express');
const app = express();
// Store processed delivery IDs to prevent duplicates
const processedDeliveries = new Set();
// express.raw keeps the exact bytes for verification; don't use express.json() on this route
app.post('/webhooks/podpdf', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifyPodPdfWebhook(req.body, req.get('PodPDF-Signature'), process.env.PODPDF_WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'invalid signature' });
}
const payload = JSON.parse(req.body);
const deliveryId = req.get('X-Webhook-Delivery-Id');
const event = req.get('X-Webhook-Event');
// Idempotency check
if (processedDeliveries.has(deliveryId)) {
return res.status(200).json({ received: true, duplicate: true });
}
// Process webhook asynchronously
processWebhook(payload, event, deliveryId)
.then(() => {
processedDeliveries.add(deliveryId);
})
.catch(err => {
console.error('Webhook processing error:', err);
});
// Return 200 immediately
res.status(200).json({ received: true });
});
async function processWebhook(payload, event, deliveryId) {
switch (event) {
case 'job.completed':
console.log('Job completed:', payload.job_id);
console.log('PDF URL:', payload.s3_url);
// Download PDF, update database, etc.
break;
case 'job.failed':
console.log('Job failed:', payload.job_id);
console.log('Error:', payload.error_message);
// Log error, notify user, etc.
break;
// Handle other events...
}
}
app.listen(3000);
Python (Flask)
Uses verify_podpdf_webhook from Verifying Signatures.
import json
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
processed_deliveries = set()
@app.route('/webhooks/podpdf', methods=['POST'])
def webhook():
# request.get_data() is the raw body; verify before parsing
if not verify_podpdf_webhook(request.get_data(), request.headers.get('PodPDF-Signature'), os.environ['PODPDF_WEBHOOK_SECRET']):
return jsonify({'error': 'invalid signature'}), 401
delivery_id = request.headers.get('X-Webhook-Delivery-Id')
event = request.headers.get('X-Webhook-Event')
payload = json.loads(request.get_data())
# Idempotency check
if delivery_id in processed_deliveries:
return jsonify({'received': True, 'duplicate': True}), 200
# Process webhook asynchronously
process_webhook(payload, event, delivery_id)
return jsonify({'received': True}), 200
def process_webhook(payload, event, delivery_id):
if event == 'job.completed':
print(f"Job completed: {payload['job_id']}")
print(f"PDF URL: {payload['s3_url']}")
# Download PDF, update database, etc.
elif event == 'job.failed':
print(f"Job failed: {payload['job_id']}")
print(f"Error: {payload['error_message']}")
# Log error, notify user, etc.
processed_deliveries.add(delivery_id)
if __name__ == '__main__':
app.run(port=3000)