Webhooks provide real-time notifications when flow executions complete, eliminating the need for polling. This guide covers building secure, production-ready webhook handlers.
Prerequisites
- Node.js 18+
- A web framework (Next.js, Express, Fastify, etc.)
- A publicly accessible URL (or ngrok for local development)
- Your webhook signing secret from the Doclo dashboard
How Webhooks Work
- Your app starts an async execution with a
webhookUrl
- Doclo processes the document
- When complete, Doclo sends an HTTP POST to your webhook URL
- Your handler verifies the signature and processes the result
Webhook Payload
Doclo sends a JSON payload with this structure:
interface WebhookEvent<T = unknown> {
event: 'execution.completed' | 'execution.failed';
timestamp: string; // ISO 8601 format
data: {
id: string;
flowId: string;
status: 'success' | 'failed';
createdAt: string;
completedAt: string;
duration: number;
output?: T; // Extracted data (if successful)
error?: { // Error details (if failed)
code: string;
message: string;
details?: Record<string, unknown>;
};
metrics?: {
tokensUsed: number;
cost: number;
stepsRun: number;
stepsTotal: number;
};
metadata?: Record<string, unknown>; // Your custom metadata
};
}
Signature Verification
Every webhook includes a signature header for authentication. Always verify signatures in production.
X-Doclo-Signature: sha256=abc123def456...
The signature is an HMAC-SHA256 hash of the raw request body using your webhook secret.
SDK Verification
The easiest way to verify signatures:
import { verifyWebhookSignature, parseWebhookEvent } from '@doclo/client';
const isValid = await verifyWebhookSignature(
rawBody, // Raw request body as string
signature, // X-Doclo-Signature header value
webhookSecret // Your DOCLO_WEBHOOK_SECRET
);
Manual Verification
If you need to verify without the SDK:
import { createHmac, timingSafeEqual } from 'crypto';
function verifySignature(
payload: string,
signature: string,
secret: string
): boolean {
// Parse the signature header
const [algorithm, providedHash] = signature.split('=');
if (algorithm !== 'sha256') {
return false;
}
// Compute expected hash
const expectedHash = createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Use timing-safe comparison to prevent timing attacks
try {
return timingSafeEqual(
Buffer.from(providedHash),
Buffer.from(expectedHash)
);
} catch {
return false;
}
}
Always use timingSafeEqual for signature comparison. Regular string comparison is vulnerable to timing attacks.
Next.js Handler
// app/api/doclo-webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyWebhookSignature, parseWebhookEvent } from '@doclo/client';
// Disable body parsing to get raw body
export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest) {
const rawBody = await request.text();
const signature = request.headers.get('x-doclo-signature');
// Validate signature exists
if (!signature) {
console.error('Webhook received without signature');
return NextResponse.json(
{ error: 'Missing signature' },
{ status: 401 }
);
}
// Verify signature
const isValid = await verifyWebhookSignature(
rawBody,
signature,
process.env.DOCLO_WEBHOOK_SECRET!
);
if (!isValid) {
console.error('Webhook signature verification failed');
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 401 }
);
}
// Parse event with timestamp validation
let event;
try {
const body = JSON.parse(rawBody);
event = parseWebhookEvent(body, {
maxAgeSeconds: 300 // Reject events older than 5 minutes
});
} catch (error) {
console.error('Failed to parse webhook event:', error);
return NextResponse.json(
{ error: 'Invalid event' },
{ status: 400 }
);
}
// Handle event types
try {
switch (event.event) {
case 'execution.completed':
await handleSuccess(event.data);
break;
case 'execution.failed':
await handleFailure(event.data);
break;
default:
console.warn('Unknown event type:', event.event);
}
} catch (error) {
// Log but still return 200 to prevent retries
console.error('Error processing webhook:', error);
}
// Return 200 quickly
return NextResponse.json({ received: true });
}
async function handleSuccess(execution: any) {
console.log('Execution completed:', execution.id);
console.log('Output:', JSON.stringify(execution.output, null, 2));
// Your processing logic:
// - Save to database
// - Notify user
// - Trigger downstream processes
}
async function handleFailure(execution: any) {
console.error('Execution failed:', execution.id);
console.error('Error:', execution.error);
// Your error handling:
// - Log for monitoring
// - Notify team
// - Queue for retry if appropriate
}
Express Handler
import express from 'express';
import { verifyWebhookSignature, parseWebhookEvent } from '@doclo/client';
const app = express();
// Use raw body for signature verification
app.post(
'/webhook/doclo',
express.raw({ type: 'application/json' }),
async (req, res) => {
const signature = req.headers['x-doclo-signature'] as string;
const rawBody = req.body.toString();
if (!signature) {
return res.status(401).json({ error: 'Missing signature' });
}
const isValid = await verifyWebhookSignature(
rawBody,
signature,
process.env.DOCLO_WEBHOOK_SECRET!
);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
try {
const event = parseWebhookEvent(JSON.parse(rawBody));
// Process event asynchronously
processWebhookEvent(event).catch(console.error);
// Return immediately
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
res.status(400).json({ error: 'Invalid event' });
}
}
);
async function processWebhookEvent(event: any) {
if (event.event === 'execution.completed') {
// Handle success
} else if (event.event === 'execution.failed') {
// Handle failure
}
}
Fastify Handler
import Fastify from 'fastify';
import { verifyWebhookSignature, parseWebhookEvent } from '@doclo/client';
const fastify = Fastify({ logger: true });
// Disable JSON parsing for this route
fastify.addContentTypeParser(
'application/json',
{ parseAs: 'string' },
(req, body, done) => done(null, body)
);
fastify.post('/webhook/doclo', async (request, reply) => {
const signature = request.headers['x-doclo-signature'] as string;
const rawBody = request.body as string;
if (!signature) {
return reply.code(401).send({ error: 'Missing signature' });
}
const isValid = await verifyWebhookSignature(
rawBody,
signature,
process.env.DOCLO_WEBHOOK_SECRET!
);
if (!isValid) {
return reply.code(401).send({ error: 'Invalid signature' });
}
const event = parseWebhookEvent(JSON.parse(rawBody));
// Process asynchronously
setImmediate(() => processEvent(event));
return { received: true };
});
Database Integration
Save extraction results to your database:
// Using Prisma
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function handleSuccess(execution: any) {
// Update extraction record
await prisma.extraction.update({
where: { executionId: execution.id },
data: {
status: 'completed',
output: execution.output,
completedAt: new Date(execution.completedAt),
duration: execution.duration,
tokensUsed: execution.metrics?.tokensUsed,
cost: execution.metrics?.cost
}
});
// If you stored a userId in metadata, notify them
if (execution.metadata?.userId) {
await notifyUser(execution.metadata.userId, {
type: 'extraction_complete',
extractionId: execution.id
});
}
}
async function handleFailure(execution: any) {
await prisma.extraction.update({
where: { executionId: execution.id },
data: {
status: 'failed',
error: execution.error,
completedAt: new Date(execution.completedAt)
}
});
// Log for monitoring
await prisma.errorLog.create({
data: {
type: 'extraction_failed',
executionId: execution.id,
errorCode: execution.error?.code,
errorMessage: execution.error?.message,
metadata: execution.metadata
}
});
}
Idempotent Processing
Webhooks may be delivered multiple times. Ensure your handler is idempotent:
async function handleSuccess(execution: any) {
// Check if already processed
const existing = await prisma.extraction.findUnique({
where: { executionId: execution.id }
});
if (existing?.status === 'completed') {
console.log('Already processed:', execution.id);
return; // Skip duplicate
}
// Process normally
await prisma.extraction.upsert({
where: { executionId: execution.id },
create: {
executionId: execution.id,
status: 'completed',
output: execution.output,
// ...
},
update: {
status: 'completed',
output: execution.output,
// ...
}
});
}
Retry Behavior
Doclo retries failed webhook deliveries automatically:
| Attempt | Delay After Previous |
|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
A delivery is considered failed if:
- Your server returns a non-2xx status code
- Connection times out (30 seconds)
- Connection cannot be established
Return a 200 response quickly, even if you’re still processing. Queue the work for async processing if it takes time.
Async Processing Pattern
For complex processing, acknowledge immediately and process async:
import { Queue } from 'bullmq';
const webhookQueue = new Queue('webhook-processing', {
connection: { host: 'localhost', port: 6379 }
});
export async function POST(request: NextRequest) {
// ... signature verification ...
const event = parseWebhookEvent(JSON.parse(rawBody));
// Queue for processing
await webhookQueue.add('process', {
event: event.event,
data: event.data,
receivedAt: new Date().toISOString()
});
// Return immediately
return NextResponse.json({ received: true });
}
Then process with a worker:
import { Worker } from 'bullmq';
const worker = new Worker('webhook-processing', async (job) => {
const { event, data } = job.data;
if (event === 'execution.completed') {
await handleSuccess(data);
} else if (event === 'execution.failed') {
await handleFailure(data);
}
}, {
connection: { host: 'localhost', port: 6379 }
});
Local Development
Use ngrok to expose your local server:
# Start your server
npm run dev
# In another terminal, expose port 3000
ngrok http 3000
Use the ngrok URL as your webhook URL:
const execution = await client.flows.run(flowId, {
input: { document },
webhookUrl: 'https://abc123.ngrok.io/api/doclo-webhook'
});
Security Best Practices
- Always verify signatures - Never process unverified webhooks
- Use HTTPS - Ensure your endpoint uses TLS
- Validate timestamps - Reject old webhooks to prevent replay attacks
- Store secrets securely - Use environment variables, never commit secrets
- Log cautiously - Don’t log sensitive data from webhook payloads
- Rate limit - Protect against abuse even with signature verification
// Timestamp validation
const event = parseWebhookEvent(body, {
maxAgeSeconds: 300 // 5 minutes
});
// This throws if the event is too old
Monitoring and Alerting
Track webhook health:
import { metrics } from './monitoring'; // Your monitoring library
async function handleWebhook(event: any) {
const startTime = Date.now();
try {
if (event.event === 'execution.completed') {
await handleSuccess(event.data);
metrics.increment('webhook.success');
} else if (event.event === 'execution.failed') {
await handleFailure(event.data);
metrics.increment('webhook.failure');
}
} catch (error) {
metrics.increment('webhook.processing_error');
throw error;
} finally {
metrics.timing('webhook.processing_time', Date.now() - startTime);
}
}
Set up alerts for:
- High webhook failure rates
- Long processing times
- Missing webhooks (executions completing without webhook delivery)
- Signature verification failures (possible security issues)
Get Your Webhook Secret
- Log in to app.doclo.ai
- Navigate to Settings > Webhooks
- Copy your webhook signing secret
- Store it as
DOCLO_WEBHOOK_SECRET in your environment
Next Steps