How do you know when a PDF has been generated? Polling the API repeatedly is wasteful. With webhooks, the API notifies your endpoint automatically when events occur — no polling needed.
This guide covers how to set up FUNBREW PDF webhooks for Slack notifications, email alerts, and system integrations.
What Are Webhooks?
Webhooks are "reverse API calls." Instead of you calling the API, the API calls your endpoint when an event happens.
Normal API: You → PDF API (request)
Webhook: PDF API → You (event notification)
Polling vs Webhooks
| Approach | How It Works | Pros | Cons |
|---|---|---|---|
| Polling | Periodically check the API | Simple to implement | Wasted requests, latency |
| Webhook | API notifies on events | Real-time, efficient | Requires public endpoint |
Webhooks are especially valuable for batch processing where you're generating many PDFs at once.
Webhook Setup
Configure in the Dashboard
In FUNBREW PDF's dashboard, navigate to the "Webhook" section:
- URL: Your endpoint that receives notifications
- Events: Select which events to subscribe to
- Secret Key: Used to verify request authenticity
Available Events
| Event | Description |
|---|---|
pdf.generated |
PDF generation completed |
pdf.failed |
PDF generation failed |
pdf.emailed |
Email delivery completed |
batch.completed |
Batch processing completed |
Webhook Payload Structure
{
"event": "pdf.generated",
"timestamp": "2026-03-30T10:00:00Z",
"data": {
"id": "pdf_abc123",
"filename": "invoice-42.pdf",
"file_size": 48210,
"download_url": "https://api.pdf.funbrew.cloud/dl/abc123?expires=...",
"engine": "quality",
"generation_time_ms": 1250
}
}
Implementing Webhook Endpoints
Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
app.post('/webhooks/funbrew-pdf', (req, res) => {
// 1. Verify signature
const signature = req.headers['x-funbrew-signature'];
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(JSON.stringify(req.body))
.digest('hex');
if (signature !== expected) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. Handle event
const { event, data } = req.body;
switch (event) {
case 'pdf.generated':
console.log(`PDF generated: ${data.filename} (${data.file_size} bytes)`);
break;
case 'pdf.failed':
console.error(`PDF failed: ${data.filename} - ${data.error}`);
break;
case 'batch.completed':
console.log(`Batch done: ${data.succeeded}/${data.total} succeeded`);
break;
}
// 3. Acknowledge receipt
res.status(200).json({ received: true });
});
app.listen(3000);
Python (FastAPI)
import hmac
import hashlib
import os
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
@app.post("/webhooks/funbrew-pdf")
async def handle_webhook(request: Request):
# 1. Verify signature
body = await request.body()
signature = request.headers.get("x-funbrew-signature", "")
expected = hmac.new(
os.environ["WEBHOOK_SECRET"].encode(),
body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="Invalid signature")
# 2. Handle event
payload = await request.json()
event = payload["event"]
data = payload["data"]
if event == "pdf.generated":
print(f"PDF generated: {data['filename']}")
elif event == "pdf.failed":
print(f"PDF failed: {data['filename']}")
return {"received": True}
PHP (Laravel)
// routes/api.php
Route::post('/webhooks/funbrew-pdf', [WebhookController::class, 'handle']);
// WebhookController.php
class WebhookController extends Controller
{
public function handle(Request $request)
{
// 1. Verify signature
$signature = $request->header('X-Funbrew-Signature');
$expected = hash_hmac('sha256', $request->getContent(), config('services.funbrew.webhook_secret'));
if (!hash_equals($expected, $signature)) {
abort(401, 'Invalid signature');
}
// 2. Handle event
$event = $request->input('event');
$data = $request->input('data');
match ($event) {
'pdf.generated' => $this->handleGenerated($data),
'pdf.failed' => $this->handleFailed($data),
'batch.completed' => $this->handleBatchCompleted($data),
default => null,
};
return response()->json(['received' => true]);
}
}
Slack Integration
Forward webhook events to a Slack channel.
async function notifySlack(event, data) {
const messages = {
'pdf.generated': `✅ PDF generated: ${data.filename} (${(data.file_size / 1024).toFixed(1)}KB)`,
'pdf.failed': `❌ PDF failed: ${data.filename} - ${data.error}`,
'batch.completed': `📦 Batch done: ${data.succeeded}/${data.total} succeeded`,
};
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: messages[event] || `PDF event: ${event}`,
channel: '#pdf-notifications',
}),
});
}
Email Notifications
Convert webhook events into email notifications so team members who don't use Slack can stay informed about PDF generation status.
Using Nodemailer
const nodemailer = require('nodemailer');
const transporter = nodemailer.createTransport({
host: 'smtp.example.com',
port: 587,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
async function sendEmailNotification(event, data) {
const templates = {
'pdf.generated': {
subject: `PDF Generated: ${data.filename}`,
html: `
<h2>PDF Generation Complete</h2>
<p><strong>File:</strong> ${data.filename}</p>
<p><strong>Size:</strong> ${(data.file_size / 1024).toFixed(1)} KB</p>
<p><strong>Generation time:</strong> ${data.generation_time_ms} ms</p>
<p><a href="${data.download_url}">Download PDF</a></p>
`,
},
'pdf.failed': {
subject: `[Action Required] PDF Failed: ${data.filename}`,
html: `
<h2>PDF Generation Failed</h2>
<p><strong>File:</strong> ${data.filename}</p>
<p><strong>Error:</strong> ${data.error}</p>
<p>Check the dashboard for error details.</p>
`,
},
'batch.completed': {
subject: `Batch Complete: ${data.succeeded}/${data.total} succeeded`,
html: `
<h2>Batch Processing Complete</h2>
<p><strong>Succeeded:</strong> ${data.succeeded}</p>
<p><strong>Failed:</strong> ${data.total - data.succeeded}</p>
`,
},
};
const template = templates[event];
if (!template) return;
await transporter.sendMail({
from: '"PDF Notifications" <noreply@example.com>',
to: process.env.NOTIFICATION_EMAIL,
...template,
});
}
Using SendGrid
For high-volume notification emails, a delivery service like SendGrid is more appropriate.
const sgMail = require('@sendgrid/mail');
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
async function sendViaSendGrid(event, data) {
// Dynamic templates let you update email designs without code changes
await sgMail.send({
to: process.env.NOTIFICATION_EMAIL,
from: 'pdf-notify@example.com',
templateId: 'd-xxxxxxxxxxxxx', // SendGrid template ID
dynamicTemplateData: {
event_type: event,
filename: data.filename,
file_size_kb: (data.file_size / 1024).toFixed(1),
download_url: data.download_url,
error: data.error || null,
generated_at: new Date().toISOString(),
},
});
}
If you're building your frontend with Next.js or Nuxt.js, you can also display real-time notification badges on your dashboard.
Internal System Integration
The real power of webhooks lies in automating business workflows. Here's how to implement a complete flow from DB updates to dashboard reflection.
Database Schema
Start with a table to track PDF generation jobs.
CREATE TABLE pdf_jobs (
id VARCHAR(255) PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
file_size INT,
download_url TEXT,
error_message TEXT,
requested_by INT REFERENCES users(id),
requested_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL,
retry_count INT DEFAULT 0
);
CREATE INDEX idx_pdf_jobs_status ON pdf_jobs(status);
CREATE INDEX idx_pdf_jobs_requested_by ON pdf_jobs(requested_by);
Status Management in the Webhook Handler
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
async function handleWebhookEvent(event, data) {
const client = await pool.connect();
try {
await client.query('BEGIN');
switch (event) {
case 'pdf.generated':
await client.query(
`UPDATE pdf_jobs
SET status = 'completed',
file_size = $1,
download_url = $2,
completed_at = NOW()
WHERE id = $3`,
[data.file_size, data.download_url, data.id]
);
// Trigger downstream workflows
await triggerDownstreamWorkflow(client, data);
break;
case 'pdf.failed':
const job = await client.query(
'SELECT retry_count FROM pdf_jobs WHERE id = $1',
[data.id]
);
if (job.rows[0]?.retry_count < 3) {
// Schedule a retry if under the limit
await client.query(
`UPDATE pdf_jobs
SET status = 'pending',
retry_count = retry_count + 1,
error_message = $1
WHERE id = $2`,
[data.error, data.id]
);
} else {
// Mark as permanently failed
await client.query(
`UPDATE pdf_jobs
SET status = 'failed',
error_message = $1,
completed_at = NOW()
WHERE id = $2`,
[data.error, data.id]
);
}
break;
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
async function triggerDownstreamWorkflow(client, data) {
// Example: forward invoice PDFs to the accounting system
const job = await client.query(
'SELECT requested_by FROM pdf_jobs WHERE id = $1',
[data.id]
);
// Notify the frontend via WebSocket or Server-Sent Events
broadcastToUser(job.rows[0].requested_by, {
type: 'pdf_ready',
filename: data.filename,
download_url: data.download_url,
});
}
Combine this with automated invoice PDF generation to trigger accounting system integrations upon completion.
Real-Time Dashboard Updates
Use Server-Sent Events (SSE) to push status changes to user dashboards in real time.
// SSE endpoint
app.get('/api/pdf-jobs/stream', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
const userId = req.user.id;
const listener = (data) => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
// Register per-user event listener
eventEmitter.on(`pdf-update:${userId}`, listener);
req.on('close', () => {
eventEmitter.off(`pdf-update:${userId}`, listener);
});
});
AWS SNS/SQS Integration
In large-scale production environments, it's better to route webhooks through a message queue rather than processing them directly. This significantly improves reliability and scalability.
Architecture
FUNBREW PDF → Webhook → Receiver → SNS → SQS → Worker Pool
↓
SQS (DLQ)
Forwarding Webhooks to SNS
const { SNSClient, PublishCommand } = require('@aws-sdk/client-sns');
const sns = new SNSClient({ region: 'us-east-1' });
const TOPIC_ARN = process.env.SNS_TOPIC_ARN;
app.post('/webhooks/funbrew-pdf', async (req, res) => {
// After signature verification...
try {
await sns.send(new PublishCommand({
TopicArn: TOPIC_ARN,
Message: JSON.stringify(req.body),
MessageAttributes: {
event_type: {
DataType: 'String',
StringValue: req.body.event,
},
},
}));
// Respond immediately (processing happens asynchronously via SQS)
res.status(200).json({ received: true });
} catch (err) {
console.error('SNS publish failed:', err);
// Fall back to local queue if SNS fails
await localQueue.add(req.body);
res.status(200).json({ received: true, queued: true });
}
});
SQS Worker Implementation
const { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } = require('@aws-sdk/client-sqs');
const sqs = new SQSClient({ region: 'us-east-1' });
const QUEUE_URL = process.env.SQS_QUEUE_URL;
async function pollMessages() {
while (true) {
const response = await sqs.send(new ReceiveMessageCommand({
QueueUrl: QUEUE_URL,
MaxNumberOfMessages: 10,
WaitTimeSeconds: 20, // Long polling
}));
for (const message of response.Messages || []) {
try {
const webhook = JSON.parse(message.Body);
const payload = JSON.parse(webhook.Message);
await processWebhookEvent(payload.event, payload.data);
// Delete message on successful processing
await sqs.send(new DeleteMessageCommand({
QueueUrl: QUEUE_URL,
ReceiptHandle: message.ReceiptHandle,
}));
} catch (err) {
console.error('Message processing failed:', err);
// Not deleting triggers automatic retry
}
}
}
}
Dead Letter Queue (DLQ) Configuration
Messages that fail repeatedly are moved to a dead letter queue for later investigation.
{
"QueueName": "funbrew-pdf-webhook-dlq",
"RedrivePolicy": {
"deadLetterTargetArn": "arn:aws:sqs:us-east-1:xxx:funbrew-pdf-webhook-dlq",
"maxReceiveCount": 3
}
}
For serverless deployments, configure a Lambda function as the SQS trigger to achieve scalable webhook processing without infrastructure management.
Error Notification Escalation
When PDF generation failures pile up, individual error notifications can get lost in the noise. Implement escalation levels that increase alert severity based on failure frequency.
Defining Escalation Levels
const ESCALATION_LEVELS = {
// Level 1: Normal error notification (Slack only)
normal: {
threshold: 1,
channels: ['slack'],
priority: 'low',
},
// Level 2: Repeated failures (Slack + email)
warning: {
threshold: 3,
channels: ['slack', 'email'],
priority: 'medium',
},
// Level 3: Critical issue (Slack + email + PagerDuty)
critical: {
threshold: 10,
channels: ['slack', 'email', 'pagerduty'],
priority: 'high',
},
};
Failure Counter Implementation
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
async function trackFailure(data) {
const key = 'pdf:failure_count';
const windowKey = 'pdf:failure_window';
// Count failures in a 1-hour sliding window
const now = Date.now();
await redis.zadd(windowKey, now, `${data.id}:${now}`);
await redis.zremrangebyscore(windowKey, 0, now - 3600000); // Remove entries older than 1 hour
const failureCount = await redis.zcard(windowKey);
// Determine escalation level
let level = ESCALATION_LEVELS.normal;
if (failureCount >= ESCALATION_LEVELS.critical.threshold) {
level = ESCALATION_LEVELS.critical;
} else if (failureCount >= ESCALATION_LEVELS.warning.threshold) {
level = ESCALATION_LEVELS.warning;
}
// Notify each channel
for (const channel of level.channels) {
await sendAlert(channel, {
message: `PDF generation failed (${failureCount} failures in the last hour)`,
filename: data.filename,
error: data.error,
priority: level.priority,
failure_count: failureCount,
});
}
}
async function sendAlert(channel, alert) {
switch (channel) {
case 'slack':
const emoji = alert.priority === 'high' ? '🚨' : alert.priority === 'medium' ? '⚠️' : '❌';
await notifySlack(`${emoji} ${alert.message}: ${alert.filename}`);
break;
case 'email':
await sendEmailNotification('pdf.failed', alert);
break;
case 'pagerduty':
await triggerPagerDuty(alert);
break;
}
}
async function triggerPagerDuty(alert) {
await fetch('https://events.pagerduty.com/v2/enqueue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
routing_key: process.env.PAGERDUTY_ROUTING_KEY,
event_action: 'trigger',
payload: {
summary: alert.message,
severity: alert.priority === 'high' ? 'critical' : 'warning',
source: 'funbrew-pdf-webhook',
},
}),
});
}
Webhook Monitoring
Ensuring webhooks are reliably received and processed is essential for production operations.
Request Logging
Log every webhook request for later investigation.
async function logWebhookRequest(req, processingResult) {
const logEntry = {
received_at: new Date().toISOString(),
event: req.body.event,
event_id: req.body.data?.id,
source_ip: req.ip,
headers: {
'x-funbrew-signature': req.headers['x-funbrew-signature'] ? '***' : 'missing',
'content-type': req.headers['content-type'],
},
response_time_ms: processingResult.duration,
status: processingResult.success ? 'success' : 'failed',
error: processingResult.error || null,
};
// Structured logging (searchable in CloudWatch, Datadog, etc.)
console.log(JSON.stringify({ type: 'webhook_log', ...logEntry }));
// Persist to DB for monitoring dashboards
await pool.query(
`INSERT INTO webhook_logs (event, event_id, response_time_ms, status, error, received_at)
VALUES ($1, $2, $3, $4, $5, $6)`,
[logEntry.event, logEntry.event_id, logEntry.response_time_ms, logEntry.status, logEntry.error, logEntry.received_at]
);
}
Metrics Collection
Expose Prometheus-format metrics and build Grafana dashboards.
const promClient = require('prom-client');
// Webhook receive counter
const webhookCounter = new promClient.Counter({
name: 'funbrew_webhook_received_total',
help: 'Total number of webhook events received',
labelNames: ['event', 'status'],
});
// Processing duration histogram
const webhookDuration = new promClient.Histogram({
name: 'funbrew_webhook_processing_seconds',
help: 'Webhook processing duration in seconds',
labelNames: ['event'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 5],
});
// Failure rate gauge
const failureRate = new promClient.Gauge({
name: 'funbrew_webhook_failure_rate',
help: 'Webhook processing failure rate (last 1 hour)',
});
// Integrate metrics into the webhook handler
app.post('/webhooks/funbrew-pdf', async (req, res) => {
const timer = webhookDuration.startTimer({ event: req.body.event });
try {
// Signature verification, event processing...
await processEvent(req.body);
webhookCounter.inc({ event: req.body.event, status: 'success' });
res.status(200).json({ received: true });
} catch (err) {
webhookCounter.inc({ event: req.body.event, status: 'error' });
res.status(500).json({ error: 'Processing failed' });
} finally {
timer();
}
});
// Metrics endpoint
app.get('/metrics', async (req, res) => {
res.set('Content-Type', promClient.register.contentType);
res.end(await promClient.register.metrics());
});
Alert Rules (Prometheus)
groups:
- name: webhook-alerts
rules:
- alert: WebhookHighFailureRate
expr: rate(funbrew_webhook_received_total{status="error"}[5m]) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "Webhook failure rate is above 10%"
- alert: WebhookProcessingSlow
expr: histogram_quantile(0.95, rate(funbrew_webhook_processing_seconds_bucket[5m])) > 5
for: 10m
labels:
severity: warning
annotations:
summary: "Webhook processing p95 latency exceeds 5 seconds"
- alert: WebhookNoEventsReceived
expr: absent(increase(funbrew_webhook_received_total[30m]))
for: 30m
labels:
severity: critical
annotations:
summary: "No webhook events received in 30 minutes"
Security Best Practices
Webhook endpoints are publicly accessible, so security is critical.
Required Protections
- Signature verification: Validate HMAC-SHA256 signatures (see code above)
- HTTPS only: Always serve webhook endpoints over TLS
- Timestamp validation: Prevent replay attacks
// Reject requests older than 5 minutes
const timestamp = new Date(req.body.timestamp);
const now = new Date();
const fiveMinutes = 5 * 60 * 1000;
if (Math.abs(now - timestamp) > fiveMinutes) {
return res.status(401).json({ error: 'Request too old' });
}
Idempotency
Webhooks may be retried. Prevent duplicate processing by tracking event IDs.
const processedEvents = new Set();
app.post('/webhooks/funbrew-pdf', (req, res) => {
const eventId = req.body.data.id;
if (processedEvents.has(eventId)) {
return res.status(200).json({ received: true, duplicate: true });
}
processedEvents.add(eventId);
// Handle event...
});
Note: The in-memory
Setabove is lost on server restart. For production, use Redis or a database instead (see "Production Best Practices" below).
Production Best Practices
Webhooks that work perfectly in development need extra care in production.
Timeout Management
Your webhook receiver must respond within 5 seconds. Offload heavy processing to a queue.
app.post('/webhooks/funbrew-pdf', async (req, res) => {
// Verify signature
if (!verifySignature(req)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Respond immediately
res.status(200).json({ received: true });
// Process in the background (runs after Express sends the response)
setImmediate(async () => {
try {
await processEvent(req.body);
} catch (err) {
console.error('Background processing failed:', err);
}
});
});
Queue-Based Processing
Use BullMQ for reliable job queue processing.
const { Queue, Worker } = require('bullmq');
const Redis = require('ioredis');
const connection = new Redis(process.env.REDIS_URL);
const webhookQueue = new Queue('webhook-processing', { connection });
// Webhook handler: enqueue and respond immediately
app.post('/webhooks/funbrew-pdf', async (req, res) => {
if (!verifySignature(req)) {
return res.status(401).json({ error: 'Invalid signature' });
}
await webhookQueue.add(req.body.event, req.body, {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: 1000,
removeOnFail: 5000,
});
res.status(200).json({ received: true });
});
// Worker: process jobs from the queue
const worker = new Worker('webhook-processing', async (job) => {
const { event, data } = job.data;
switch (event) {
case 'pdf.generated':
await handleGenerated(data);
break;
case 'pdf.failed':
await trackFailure(data);
break;
case 'batch.completed':
await handleBatchCompleted(data);
break;
}
}, {
connection,
concurrency: 5, // Parallel processing limit
});
worker.on('failed', (job, err) => {
console.error(`Job ${job.id} failed:`, err);
});
Production-Grade Idempotency
Replace the in-memory Set with Redis, using TTL to prevent unbounded memory growth.
async function isProcessed(eventId) {
const key = `webhook:processed:${eventId}`;
// NX: only set if key doesn't exist
// EX: auto-delete after 24 hours
const result = await redis.set(key, '1', 'NX', 'EX', 86400);
return result === null; // null means already processed
}
app.post('/webhooks/funbrew-pdf', async (req, res) => {
if (!verifySignature(req)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const eventId = req.body.data.id;
if (await isProcessed(eventId)) {
return res.status(200).json({ received: true, duplicate: true });
}
await webhookQueue.add(req.body.event, req.body);
res.status(200).json({ received: true });
});
Health Check Endpoint
Monitor your webhook receiver's availability.
app.get('/health', async (req, res) => {
const checks = {
redis: false,
database: false,
queue: false,
};
try {
await redis.ping();
checks.redis = true;
} catch (e) {}
try {
await pool.query('SELECT 1');
checks.database = true;
} catch (e) {}
try {
const counts = await webhookQueue.getJobCounts();
checks.queue = true;
checks.queueSize = counts;
} catch (e) {}
const healthy = Object.values(checks).every(v => v !== false);
res.status(healthy ? 200 : 503).json({ status: healthy ? 'ok' : 'degraded', checks });
});
Check the pricing comparison for webhook delivery limits and retry counts per plan.
Local Development Testing
Use tunneling tools to receive webhooks locally.
# Expose local server with ngrok
ngrok http 3000
# → https://abc123.ngrok.io
# Register this URL in the dashboard webhook settings
Generate a test PDF from the Playground to trigger webhook events to your local endpoint.
Summary
What webhook integration enables:
- Real-time notifications: Detect PDF completion instantly
- Slack and email integration: Automatic team notifications with escalation
- System integration: DB updates, status management, dashboard reflection
- Message queues: Reliable large-scale processing via AWS SNS/SQS
- Monitoring: Request logging, response time tracking, failure rate metrics
- Production readiness: Timeout management, queue processing, idempotency
Always implement signature verification, HTTPS, and timestamp validation for security. In production, keep your webhook handler lightweight and delegate heavy processing to a queue.
Start with the free plan to test webhooks. Check the API documentation for event specifications. Combine with Markdown to PDF conversion or report generation for end-to-end automation.
Related
- PDF Batch Processing Guide — Combine batch processing with webhooks
- Automate Invoice PDF Generation — Invoice workflow with notifications
- PDF API Security Guide — Endpoint security best practices
- PDF API Quickstart — Basic API calls in each language
- Next.js and Nuxt.js Integration — Frontend framework integration
- Markdown to PDF Guide — Generate PDFs from Markdown
- PDF API Production Guide — Production deployment best practices
- PDF API Pricing Comparison — Plan features and limits
- PDF Report Generation Guide — Automated report PDF generation
- HTML to PDF CSS Tips — CSS techniques for PDF output
- Django/FastAPI Integration — Python framework webhook integration
- Serverless Lambda Integration — Async processing with AWS Lambda
- API Documentation — Webhook event specifications