Skip to main content
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

  1. Your app starts an async execution with a webhookUrl
  2. Doclo processes the document
  3. When complete, Doclo sends an HTTP POST to your webhook URL
  4. 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.

Header Format

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:
AttemptDelay After Previous
1Immediate
21 minute
35 minutes
430 minutes
52 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

  1. Always verify signatures - Never process unverified webhooks
  2. Use HTTPS - Ensure your endpoint uses TLS
  3. Validate timestamps - Reject old webhooks to prevent replay attacks
  4. Store secrets securely - Use environment variables, never commit secrets
  5. Log cautiously - Don’t log sensitive data from webhook payloads
  6. 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

  1. Log in to app.doclo.ai
  2. Navigate to Settings > Webhooks
  3. Copy your webhook signing secret
  4. Store it as DOCLO_WEBHOOK_SECRET in your environment

Next Steps