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
Webhooks Webhook configuration reference
Next.js Integration Complete Next.js example
Error Recovery Handle failures gracefully
Polling Results Alternative to webhooks