Skip to main content

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-Signature header 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.completed or job.failed)
  • Delivery tracking - View delivery history, success/failure counts, and statistics
  • Activation control - Enable/disable webhooks as needed (is_active field)
  • 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 ID
  • plan_type - Plan type
  • current_count - Number of webhooks you currently have
  • max_allowed - Maximum webhooks allowed for your plan
  • upgrade_required - Whether you need to upgrade to get more webhooks

Event Types​

You can subscribe to the following events:

EventDescriptionWhen Triggered
job.completedJob successfully completedLong job finishes PDF generation, or a merge/split job finishes
job.failedJob failed during processingPDF generation error, Chromium crash, or a merge/split job fails
job.queuedJob queued for processingLong job submitted and queued
job.processingJob started processingLong job extracted from queue and processing
bulk.job.completedBulk job converted every fileBulk job finishes with no failures
bulk.job.partialBulk job converted some filesBulk job finishes with failed or skipped files
bulk.job.failedBulk job produced nothingBulk job was rejected, or no file converted
bulk.job.cancelledBulk job was cancelledPOST /jobs/{job_id}/cancel stopped it. success_count and s3_url cover any PDFs made before the cancel
mailmerge.campaign.completedEvery valid row was sentA mail merge campaign finishes with every email accepted
mailmerge.campaign.partialSome rows were not sentA campaign finishes with failed, rejected or unconfirmed rows, or stops early (error_code says why)
mailmerge.campaign.failedNothing was sentEvery row failed, or the campaign could not start
mailmerge.campaign.cancelledCampaign was cancelledPOST /jobs/{job_id}/cancel stopped it. sent_count covers emails sent before the cancel
job.timeoutReservedSubscribable, 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
}
Save the secret now

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 seconds
  • v1 — a hex HMAC-SHA256 of the string <t>.<raw request body>, keyed with your webhook secret

To verify a delivery:

  1. Read the raw request body, before any JSON parsing
  2. Parse t and every v1 from the header
  3. Reject the request if t is more than 5 minutes from your current time
  4. Compute HMAC-SHA256 of `${t}.${rawBody}` with your secret
  5. Compare it to each v1 using 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​

verify-webhook.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​

verify_webhook.py
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​

Verify the raw body

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. Use crypto.timingSafeEqual or hmac.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 webhook
  • url (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)
  • 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
  • localhost and hostnames ending in .localhost, .local or .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::/10 and 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 (true or false)
  • 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)
  • 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 name
  • url (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 events
  • is_active (boolean, optional) - Update active status. Set to false to 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
}
Disable Without Deleting

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, from 0 to 604800 (7 days). Default: 86400 (24 hours). Use 0 to 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
  • 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)
  • 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 idempotency
  • job_id (string) - Job ID that triggered this webhook
  • event_type (string) - Event type that triggered webhook
  • status (string) - Delivery status: success, failed, or timeout
  • status_code (number, optional) - HTTP status code from webhook endpoint
  • error_message (string, optional) - Error message if delivery failed
  • retry_count (number) - Number of retry attempts (0-3)
  • delivered_at (string) - ISO 8601 timestamp when delivery completed
  • duration_ms (number) - Total delivery duration in milliseconds
  • payload_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)​

Not currently delivered

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_id from X-Webhook-Delivery-Id header to deduplicate

Webhook Receiver Best Practices​

  1. Verify the signature - Reject any request whose PodPDF-Signature doesn't verify, with 401. See Verifying Signatures
  2. Use delivery_id for idempotency - Store X-Webhook-Delivery-Id to prevent duplicate processing; retries of one delivery share the same ID
  3. Return 200 OK quickly - Process webhook asynchronously if needed
  4. Validate payload structure - Check required fields and types
  5. Handle all event types - Even if you only subscribe to some events
  6. 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:

FieldTypeDescription
is_activebooleanWhether webhook is active. Inactive webhooks are not called.
success_countnumberTotal successful deliveries (starts at 0, increments on each success)
failure_countnumberTotal failed deliveries (starts at 0, increments on each failure after all retries)
last_triggered_atstring|nullISO 8601 timestamp when webhook was last triggered (null if never triggered)
last_success_atstring|nullISO 8601 timestamp of last successful delivery (null if no successes)
last_failure_atstring|nullISO 8601 timestamp of last failed delivery (null if no failures)

Delivery Status​

Webhook delivery history includes status information:

StatusDescription
successWebhook was successfully delivered (HTTP 2xx response)
failedWebhook delivery failed after all retries (HTTP 4xx/5xx, network error, timeout)
timeoutWebhook delivery timed out (10 second timeout)

Error Responses​

StatusError CodeDescription
400 Bad RequestINVALID_WEBHOOK_URLURL must be HTTPS (1-2048 characters) and must not point at a private or internal address (URL requirements)
400 Bad RequestINVALID_EVENTSInvalid event type or empty events array
400 Bad RequestINVALID_PARAMETERgrace_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 ForbiddenACCOUNT_NOT_FOUNDUser account not found
403 ForbiddenWEBHOOK_LIMIT_EXCEEDEDMaximum webhooks reached for plan (includes plan details)
403 ForbiddenWEBHOOK_ACCESS_DENIEDWebhook belongs to different user
404 Not FoundWEBHOOK_NOT_FOUNDWebhook 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)

Need Help?​