Webhooks
Get a signed HTTP request when a PDF is created, when one fails and when a batch finishes, with retries for about a day and a delivery log.
On this page
A webhook is an HTTP request CastPDF sends to your server when something happens, so you do not have to poll. Webhooks are included in the Starter, Growth, Pro and Scale plans. Add endpoints in the dashboard or with POST /v1/webhooks.
Events
| Event | Sent when | data |
|---|---|---|
pdf.created | A PDF was created | The document, plus filename, metadata and batch_id |
pdf.failed | A PDF could not be created | The same, with status: "failed", error_code and error_message |
batch.completed | Every item of a batch is done (or the batch was canceled) | The batch, with its ZIP link |
PDF events are sent for documents made with an API key (direct and background), for batch items and for the dashboard's Generate PDF button. Watermarked previews in the dashboard's editor send nothing.
Each endpoint has a mode. A live endpoint receives the events of live PDFs; a test endpoint those of test PDFs (made with a test key, or a test batch). A live API key manages live endpoints and a test key test endpoints; the dashboard manages both.
What your server receives
A POST with a JSON body:
{
"id": "evt_6f1d2c3b4a5e4f609b7a1c2d3e4f5a6b",
"type": "pdf.created",
"created_at": "2026-10-03T10:15:03.120Z",
"livemode": true,
"data": {
"id": "3f0c9a4e-6b1d-4c2a-9a57-0d8e4b7f21c3",
"status": "succeeded",
"pages": 2,
"bytes": 48213,
"mode": "print",
"test": false,
"template_id": "8d1f2c3b-4a5e-4f60-9b7a-1c2d3e4f5a6b",
"template_version": 4,
"error_code": null,
"created_at": "2026-10-03T10:15:02.123Z",
"expires_at": "2026-10-10T10:15:02.123Z",
"url": "https://api.castpdf.com/v1/files/3f0c9a4e-6b1d-4c2a-9a57-0d8e4b7f21c3?exp=1791972902&sig=Jx0k…",
"delivery": null,
"filename": "invoice-042.pdf",
"metadata": { "order_id": "1042" },
"batch_id": null
}
}url is a signed download link that works until expires_at. Your own metadata comes back unchanged, which is the easiest way to connect a PDF to your order or record.
Headers:
| Header | Value |
|---|---|
CastPDF-Signature | t=<unix seconds>,v1=<signature>; see verifying |
CastPDF-Event-Id | The event's id. The same event sent to several endpoints, or sent again, has the same id: use it to ignore duplicates. |
CastPDF-Event-Type | The event's type |
User-Agent | CastPDF-Webhooks/1.0 |
Content-Type | application/json |
Answer with any 2xx status within 10 seconds. Do the slow work after answering. Redirects are not followed, and only the first 4 KB of your answer is read.
Verifying the signature
Every request is signed with your endpoint's secret (whsec_..., shown when you create the endpoint). The signature is a hex HMAC-SHA256, keyed with the secret, of the timestamp t, a dot and the raw request body. Verify it before trusting the request, and refuse timestamps older than 5 minutes to stop replays. Use the raw body exactly as received: parsing and re-serializing the JSON changes it.
import crypto from 'node:crypto';
import express from 'express';
const SECRET = process.env.CASTPDF_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;
function verify(header, rawBody, secret) {
const parts = header.split(',').map((p) => p.trim());
const t = Number(parts.find((p) => p.startsWith('t='))?.slice(2));
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = Buffer.from(crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'));
return parts
.filter((p) => p.startsWith('v1='))
.some((p) => {
const given = Buffer.from(p.slice(3));
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
const app = express();
app.post('/castpdf-webhook', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verify(req.get('CastPDF-Signature') ?? '', rawBody, SECRET)) return res.status(400).send('bad signature');
const event = JSON.parse(rawBody);
res.sendStatus(200); // answer first, then work
if (event.type === 'pdf.created') console.log('PDF ready:', event.data.url);
});
app.listen(3000);import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
SECRET = os.environ["CASTPDF_WEBHOOK_SECRET"] # whsec_...
TOLERANCE_SECONDS = 300
app = Flask(__name__)
def verify(header: str, raw_body: bytes, secret: str) -> bool:
parts = [p.strip() for p in header.split(",")]
t = next((p[2:] for p in parts if p.startswith("t=")), "")
if not t.isdigit() or abs(time.time() - int(t)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(p[3:], expected) for p in parts if p.startswith("v1="))
@app.post("/castpdf-webhook")
def castpdf_webhook():
raw_body = request.get_data()
if not verify(request.headers.get("CastPDF-Signature", ""), raw_body, SECRET):
return "bad signature", 400
event = json.loads(raw_body)
if event["type"] == "pdf.created":
print("PDF ready:", event["data"]["url"])
return "", 200The header can hold more than one v1= value: right after you rotate the secret, each request is signed with the new and the old secret. Accept the request when any of them matches.
Retries and automatic disabling
A delivery that does not get a 2xx in time is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 7 hours and 8 hours: 8 attempts over about a day. Events can therefore arrive late, more than once (use CastPDF-Event-Id) and out of order (use created_at, or fetch the latest state with GET /v1/pdf/:id).
When an endpoint has failed every attempt for 24 hours, it is disabled (disabled_reason: "failing"), its waiting deliveries are dropped, and your team's owners and admins get an email. Fix your server, then enable the endpoint again in the dashboard or with PUT /v1/webhooks/:id. Events that happen while an endpoint is disabled are not sent later.
Delivery log and test events
The dashboard and GET /v1/webhooks/:id/deliveries show every delivery of the last 30 days: its status, attempts, your server's HTTP status and the start of its answer. A finished delivery can be sent again.
Send test event (or POST /v1/webhooks/:id/test) sends a sample pdf.created with "test_event": true and made-up data, once. A failing test event never disables an endpoint.
Rotating the secret
Create a new secret in the dashboard or with POST /v1/webhooks/:id/rotate-secret. For 24 hours both secrets sign every request, so you can deploy the new one without missing an event.
Security of endpoint URLs
Endpoint URLs must use https, port 443, 80 or 8443, and point to the public internet: addresses in private, loopback and link-local ranges are refused when you add the endpoint and checked again at every delivery, and a URL may not contain a user name or password. A workspace can have 10 endpoints. Signing secrets are stored encrypted. CastPDF sends at most 2 deliveries to one endpoint at a time, so a slow endpoint delays only its own events.