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

EventDescription
pdf.createdA PDF was generated from a template (API, MCP or no-code), or from raw HTML.
pdf.failedA generation failed, for example because of a Twig error in the template.
pdf.mergeSeveral documents were merged into one PDF.
quota.warningYour usage reached 80% or 95% of the monthly quota.
pack.warningYour prepaid credits reached 90% or 95% used.
welcome_pack.grantedThe one-off welcome pack was added to a paying account that crossed its quota.
quota.exhaustedThe 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_dueA renewal payment failed. Your plan holds for 7 days while you update your payment method.

Creating a webhook

  1. Go to Settings → Webhooks
  2. Click Create webhook
  3. Enter a name and the destination URL
  4. Choose the event to listen to, or All events
  5. Choose a template, or All templates
  6. 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.

pdf.createdjson
{
  "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.

pdf.failedjson
{
  "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.

pdf.mergejson
{
  "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_url
  • pack.warning: threshold, percentage, pack_credits, pack_total, purchase_url
  • welcome_pack.granted: credits, purchase_url
  • quota.exhausted: limit, plan, purchase_url
  • subscription.past_due: grace_ends_at, manage_url

Request headers

Each webhook request includes the following headers:

  • Content-Type: application/json
  • X-Webhook-Signature: hex-encoded HMAC-SHA256 of the raw body, keyed with the webhook's signing secret
  • X-Webhook-Event: the event name (e.g. pdf.created)
  • X-Webhook-Timestamp: when the request was sent, in RFC 3339 format
  • X-Webhook-Delivery: the delivery ID. It is the same on every attempt of a delivery, so use it to ignore duplicates
  • X-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.

Signature computationtext
hex(HMAC-SHA256(webhook_secret, raw_request_body))

Verification in PHP

Verify webhook signaturephp
<?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.

Verify webhook signaturejavascript
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
Security tip
Always verify the webhook signature before processing the payload. Use a constant-time comparison function (like hash_equals in PHP or timingSafeEqual in Node.js) to prevent timing attacks.