How to Receive Webhook Events in a PHP PDF Workflow
Verify webhook events in PHP, prevent duplicate PDFs, queue rendering, and choose a reliable PDF library for automated documents.
Direct answer: expose a public HTTPS PHP endpoint, read the raw request body and signature header, verify the signature before decoding JSON, record the provider event ID with a unique constraint, acknowledge only after durable handoff, and render the PDF in a worker. For Stripe, use \Stripe\Webhook::constructEvent(); its default timestamp tolerance is 300 seconds. The workflow below shows a complete implementation and the operational details that prevent forged events, duplicate documents, and lost jobs.
1. The webhook-to-PDF workflow
- Create a public HTTPS endpoint such as
https://example.com/webhooks/stripe. - Register the endpoint with the provider and enable only the event types the application needs. Stripe supports endpoint registration in the Dashboard or API; see the Stripe webhook documentation.
- Read
php://inputand the provider signature header before parsing or normalizing the body. - Verify the signature with the provider’s official PHP helper.
- Validate the event type and required fields.
- Insert the event ID into a table with a unique constraint. If it already exists, return success without generating another PDF.
- Put a job containing the event ID and validated business data on a durable queue.
- Return a success response after the event is durably recorded or queued.
- Have a worker render the PDF, store it, and record its template version and storage key.
Keeping PDF rendering outside the HTTP request makes retries safe and keeps slow HTML rendering, fonts, and remote assets away from the acknowledgement path.
2. Install PHP dependencies
Install the Stripe SDK and one PDF engine with Composer. The examples use Dompdf:
composer require stripe/stripe-php dompdf/dompdf
Dompdf is a pure-PHP HTML/CSS renderer with Composer installation requirements documented in its official repository. For UTF-8, text-heavy documents, mPDF is another option; its installation and temporary-directory guidance are in the mPDF documentation. New projects that need the modern TCPDF stack can use tc-lib-pdf, which requires PHP 8.2 or later; the legacy TCPDF repository is deprecated. See the tc-lib-pdf repository.
3. Build the verified PHP endpoint
Store STRIPE_WEBHOOK_SECRET in deployment configuration, never in source control. The exact raw body must be passed to the verifier before JSON decoding.
<?php
// public/webhooks/stripe.php
require dirname(__DIR__, 2) . '/vendor/autoload.php';
$payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
$secret = $_ENV['STRIPE_WEBHOOK_SECRET'] ?? getenv('STRIPE_WEBHOOK_SECRET');
if ($payload === false || $secret === false || $secret === null || $secret === '') {
http_response_code(400);
exit('Missing request or configuration');
}
try {
$event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret);
} catch (\UnexpectedValueException $e) {
// Invalid JSON or malformed payload.
http_response_code(400);
exit('Invalid payload');
} catch (\Stripe\Exception\SignatureVerificationException $e) {
// Invalid signature, wrong secret, or an expired timestamp.
http_response_code(400);
exit('Invalid signature');
}
$eventId = $event->id;
$eventType = $event->type;
// Insert the event with a UNIQUE constraint before doing expensive work.
// If insertEventOnce() returns false, this is a previously processed delivery.
$inserted = insertEventOnce($eventId, $eventType, $payload);
if (!$inserted) {
http_response_code(200);
echo 'already received';
exit;
}
// Queue a durable job. Pass the event ID, not an unverified request body.
queuePdfJob([
'event_id' => $eventId,
'event_type' => $eventType,
]);
http_response_code(200);
echo 'ok';
Stripe’s official helper rejects invalid JSON and signatures. Its documented default tolerance is 300 seconds (five minutes); keep server clocks synchronized and do not disable timestamp checking unless you have a specific, reviewed reason. The Stripe signature guide explains the signing model.
4. Make delivery idempotent
Providers retry deliveries. The event ID is the stable key for deduplication. A relational schema can look like this:
CREATE TABLE webhook_events (
event_id VARCHAR(255) PRIMARY KEY,
event_type VARCHAR(255) NOT NULL,
payload_json JSON NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP NULL,
status VARCHAR(32) NOT NULL DEFAULT 'received',
error_message TEXT NULL
);
CREATE TABLE generated_pdfs (
event_id VARCHAR(255) PRIMARY KEY,
event_type VARCHAR(255) NOT NULL,
template_version VARCHAR(64) NOT NULL,
storage_key VARCHAR(1024) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
Implement the insert as an atomic operation such as INSERT ... ON CONFLICT DO NOTHING (PostgreSQL) or INSERT IGNORE/ON DUPLICATE KEY handling (MySQL). Mark the event processed only after the PDF and storage record exist. If a worker crashes, a retry can safely resume from the recorded event.
5. Choose a PHP PDF engine
| Engine | Best fit | Operational points |
|---|---|---|
| Dompdf | HTML/CSS templates with modest layout needs | Pure PHP and Composer install. Treat remote stylesheets and images as an explicit security and configuration decision. |
| mPDF | UTF-8, text-heavy documents | Converts UTF-8 HTML to PDF. Configure a dedicated writable temporary directory. |
| tc-lib-pdf | New projects needing the modern TCPDF stack or lower-level PDF control | Requires PHP 8.2 or later and is installed with Composer. The legacy TCPDF codebase is deprecated. |
Compare engines using the layout features your templates actually need: CSS fidelity, fonts and Unicode, image handling, memory use, PHP version, remote-resource controls, licensing, and maintenance status. Do not select on an unverified benchmark.
6. Render and store the PDF in a worker
The worker should load business data from your database, render a deterministic template, write to temporary storage, move the completed file to durable storage, and then update the database.
<?php
// worker/render_invoice.php
require dirname(__DIR__) . '/vendor/autoload.php';
use Dompdf\Dompdf;
use Dompdf\Options;
function renderInvoicePdf(array $invoice, string $outputPath): void
{
$options = new Options();
$options->setIsRemoteEnabled(false); // Enable only with an allow-list if required.
$options->setDefaultFont('DejaVu Sans');
$dompdf = new Dompdf($options);
$html = renderInvoiceTemplate($invoice); // Escape values in the template.
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
$bytes = $dompdf->output();
$tmp = $outputPath . '.tmp';
file_put_contents($tmp, $bytes, LOCK_EX);
rename($tmp, $outputPath); // Atomic replacement on the same filesystem.
}
// Fetch the event and invoice by event_id, then call renderInvoicePdf().
Keep remote assets disabled unless the template needs them. If external images or stylesheets are required, allow-list hosts, use HTTPS, set bounded timeouts, and avoid inserting user-controlled URLs directly into the HTML.
7. Test requests with cURL, Python, and Node.js
A real provider signature must be generated with the provider’s signing secret. These examples are useful for testing routing, but a production endpoint should reject unsigned requests.
curl -i -X POST https://example.com/webhooks/stripe \
-H 'Content-Type: application/json' \
-d '{"id":"evt_test_123","type":"invoice.paid","data":{"object":{}}}'
import requests
payload = {"id": "evt_test_123", "type": "invoice.paid", "data": {"object": {}}}
r = requests.post(
"https://example.com/webhooks/stripe",
json=payload,
timeout=15,
)
print(r.status_code, r.text)
const payload = {
id: 'evt_test_123',
type: 'invoice.paid',
data: { object: {} }
};
const res = await fetch('https://example.com/webhooks/stripe', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
});
console.log(res.status, await res.text());
For integration tests, use the provider’s official test-event mechanism so the signature header is authentic. Never weaken verification in production just to make a local fixture pass.
8. Security checklist
- Require HTTPS and verify the provider signature on every request.
- Read the raw body before JSON decoding, trimming, or re-encoding.
- Keep webhook secrets in environment or secret-manager configuration and rotate them through deployment.
- Use a unique event-ID constraint and make PDF writes idempotent.
- Validate event type, object IDs, amounts, currency, and account context before generating a document.
- Do not log signatures, secrets, full payment data, or unnecessary personal information.
- Disable remote PDF resources by default; allow-list any required hosts.
- Limit request size and reject unsupported content types.
- Use least-privilege credentials for queues and object storage.
9. Reliability, performance, and cost
- Reliability: acknowledge after durable insertion or queue handoff. A queue with retries and a dead-letter path makes failures visible without creating duplicate PDFs.
- Performance: keep the endpoint small; move template rendering, font loading, image fetching, and storage to workers. Reuse worker processes where your PDF library supports it, and measure memory per document.
- Storage: store the PDF key plus event ID, event type, template version, and creation time. Keep the source business data needed to regenerate the document.
- Retention: Stripe documents a 30-day guarantee for Events API retrieval. Persist the data required for later regeneration instead of relying on provider retrieval indefinitely.
- Cost: estimate queue time, CPU, memory, object storage, and outbound transfer per document. Cache immutable assets and avoid downloading the same fonts or images for every job.
- Observability: record event ID, processing status, duration, PDF byte size, retry count, and failure reason. Correlate endpoint and worker logs with the event ID.
10. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| 400 Invalid signature | Wrong secret, modified body, missing header, or timestamp outside tolerance | Use the endpoint-specific secret, pass the untouched raw body, forward the signature header, and check clock synchronization. |
| Every delivery creates two PDFs | No unique event-ID constraint or the check and insert are not atomic | Add a primary/unique key and perform an atomic insert before queueing. |
| Provider keeps retrying | Non-2xx response, process crash, or acknowledgement occurs before durable handoff | Inspect HTTP and application logs; return success only after the event is durably recorded. |
| PDF worker loses jobs | In-memory queue or event marked complete too early | Use a durable queue, retry policy, and mark completion after storage succeeds. |
| Blank or incomplete PDF | Unsupported CSS, missing fonts, blocked remote resources, or rendering timeout | Reduce template complexity, embed or provision fonts, configure an allow-list, and capture renderer errors. |
| Images or CSS do not appear | Remote access disabled, invalid URL, TLS failure, or blocked host | Prefer local assets; otherwise allow-list HTTPS hosts and set bounded network timeouts. |
| Unicode characters render as boxes | Font lacks required glyphs | Install a Unicode font with the needed character coverage and configure the engine to use it. |
| Out-of-memory errors | Large HTML, high-resolution images, or concurrent workers | Resize assets, paginate content, limit worker concurrency, and measure peak memory. |
| Local tests fail while production works | Fixture lacks a valid provider signature | Use the provider’s test signing flow or a verified SDK fixture; do not bypass verification. |
11. FAQ
Should I decode JSON before verifying it?
No. Verify the exact raw request body first, then inspect the decoded event.
Can I generate the PDF inside the webhook request?
You can for trivial documents, but a durable queue is safer when rendering or storage can take noticeable time.
How do I handle an event type I do not use?
Verify it, record enough information for audit, and acknowledge it without creating a PDF.
Which PDF library should I start with?
Use Dompdf for modest HTML/CSS templates, mPDF for UTF-8 text-heavy output, and tc-lib-pdf for new projects that need the modern TCPDF stack and PHP 8.2+.
What should I retain for regeneration?
Keep the business data, event ID, event type, template version, and storage key. Do not depend solely on provider event retrieval.
12. Or skip the browser setup
If the PDF workflow also needs a clean webpage screenshot or rendered page asset, ScreenshotNeo provides a website screenshot API and MCP server. It accepts a URL and returns PNG, JPEG, WebP, or PDF. Cookie and consent banners, newsletter popups, and chat widgets are removed before capture; bot checks, blank pages, timeouts, failed loads, and cache hits are not billed. Responses identify the page verdict and billing status with X-Page-Verdict and X-Billed headers. Its MCP tools let Claude, Cursor, and other MCP clients take screenshots, inspect pages, and capture PDFs.
See the ScreenshotNeo API documentation for all options. This is the one-call version:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
Every feature is included on every plan. The free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000. Create a free ScreenshotNeo account.


