Webhooks
Receive real-time notifications when PDFs are generated, fail, or when your quota runs low.
Overview
Webhooks let you subscribe to events in pdfstork and receive HTTP POST notifications at a URL you specify. This is useful for workflows where you need to process the generated PDF automatically (e.g. send it by email, store it in a CRM, etc.).
Webhooks are available on the Pro plan.
Available events
| Event | Description |
|---|---|
pdf.created | A PDF was generated from a template (API, MCP or no-code), or from raw HTML. |
pdf.failed | A generation failed, for example because of a Twig error in the template. |
pdf.merge | Several documents were merged into one PDF. |
quota.warning | Your usage reached 80% or 95% of the monthly quota. |
pack.warning | Your prepaid credits reached 90% or 95% used. |
welcome_pack.granted | The one-off welcome pack was added to a paying account that crossed its quota. |
quota.exhausted | The monthly quota and the prepaid credits are both used up: the API answers 402 until you buy credits, change plan or the quota resets. Sent once a month. |
subscription.past_due | A renewal payment failed. Your plan holds for 7 days while you update your payment method. |
Creating a webhook
- Go to Settings → Webhooks
- Click Create webhook
- Enter a name and the destination URL
- Choose the event to listen to, or All events
- Choose a template, or All templates
- Click Create
A webhook tied to one template only receives the PDF events (pdf.created, pdf.failed) of that template. Quota and credit events, merges and PDFs rendered from raw HTML are sent to the webhooks set to All templates.
A unique signing secret is generated for each webhook. You need it to verify the signature of incoming requests.
Payload format
When an event is triggered, pdfstork sends a POST request to your URL with a JSON body. The timestamp is in RFC 3339 format, in UTC, without fractional seconds.
{
"event": "pdf.created",
"timestamp": "2026-09-28T15:44:17Z",
"data": {
"template_id": 123,
"template_name": "Invoice",
"filename": "invoice-001.pdf",
"size": 12345,
"pdf_url": "https://de-s3.storage.bunnycdn.com/pdfapi-pdfs/pdfs/abc123.pdf?X-Amz-Signature=...",
"pdf_url_expires_at": "2026-09-29T15:44:17Z"
}
}The PDF itself is not in the payload. Download it from pdf_url, a signed link valid for 24 hours (until pdf_url_expires_at). For a PDF rendered from raw HTML, template_id and template_name are absent.
{
"event": "pdf.failed",
"timestamp": "2026-09-28T15:44:17Z",
"data": {
"template_id": 123,
"template_name": "Invoice",
"error": "Twig error in the template, line 2: missing endif (the if opened here is never closed)",
"part": "template",
"line": 2
}
}template_id and template_name are absent for raw HTML; part and line are only present for a Twig error.
{
"event": "pdf.merge",
"timestamp": "2026-09-28T15:44:17Z",
"data": {
"template_ids": [123, 456],
"filename": "bundle.pdf",
"size": 45678,
"pdf_url": "https://de-s3.storage.bunnycdn.com/pdfapi-pdfs/pdfs/def456.pdf?X-Amz-Signature=...",
"pdf_url_expires_at": "2026-09-29T15:44:17Z"
}
}The account events carry the numbers you need to act:
quota.warning:threshold,percentage,current,limit,purchase_urlpack.warning:threshold,percentage,pack_credits,pack_total,purchase_urlwelcome_pack.granted:credits,purchase_urlquota.exhausted:limit,plan,purchase_urlsubscription.past_due:grace_ends_at,manage_url
Request headers
Each webhook request includes the following headers:
Content-Type: application/jsonX-Webhook-Signature: hex-encoded HMAC-SHA256 of the raw body, keyed with the webhook's signing secretX-Webhook-Event: the event name (e.g.pdf.created)X-Webhook-Timestamp: when the request was sent, in RFC 3339 formatX-Webhook-Delivery: the delivery ID. It is the same on every attempt of a delivery, so use it to ignore duplicatesX-Webhook-Attempt: the attempt number, starting at 1
Verifying signatures
Every webhook request is signed with your webhook's secret using HMAC-SHA256. You should always verify the signature to ensure the request is authentic.
The signature is computed over the raw body of the request, exactly as received. Compute it before parsing the JSON: a body parsed and serialized again may differ by a single byte and fail the check.
hex(HMAC-SHA256(webhook_secret, raw_request_body))Verification in PHP
<?php
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$secret = 'your_webhook_secret';
$expected = hash_hmac('sha256', $payload, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit('Invalid signature');
}
$data = json_decode($payload, true);
// Process the webhook...Verification in Node.js (Express)
Read the body with express.raw({ type: 'application/json' }) on the webhook route, before any express.json(), so that req.body is the raw buffer the signature was computed on.
const crypto = require('crypto');
const express = require('express');
function verifyWebhook(rawBody, signature, secret) {
if (typeof signature !== 'string') {
return false;
}
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
);
const received = Buffer.from(signature);
// timingSafeEqual throws when the lengths differ.
if (received.length !== expected.length) {
return false;
}
return crypto.timingSafeEqual(expected, received);
}
const app = express();
app.post(
'/webhooks/pdfstork',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.get('X-Webhook-Signature');
if (!verifyWebhook(req.body, signature, process.env.PDFSTORK_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// Process the webhook...
res.sendStatus(200);
}
);Retry policy
If your endpoint is unreachable, does not answer within 10 seconds, or returns a status code outside 2xx, pdfstork retries the delivery 3 times:
- 1st retry: after 10 seconds
- 2nd retry: after 1 minute
- 3rd retry: after 5 minutes
If the last retry fails too, the delivery is given up and marked as failed. You can still send it again by hand from its history.
Delivery history
In Settings → Webhooks, the History button of a webhook lists its latest deliveries. Each delivery shows:
- Status: pending, retrying, success or failed
- The event
- HTTP status code returned by your endpoint
- Response body
- Duration (in ms)
- Number of attempts
- When the next retry is due, if one is planned
The Redeliver button sends a delivery again. Deliveries are kept for 30 days.
Managing webhooks
The … menu of each webhook offers:
- Send test event: send a test payload to check that your endpoint works
- Signing secret: show and copy the secret. Regenerate replaces it; the old secret stops verifying immediately, so update your endpoint at the same time
- Disable / Enable: pause deliveries without deleting the webhook
- Delete: remove a webhook to stop receiving notifications
hash_equals in PHP or timingSafeEqual in Node.js) to prevent timing attacks.