> ## Documentation Index
> Fetch the complete documentation index at: https://docs.doclo.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook Handler

> Build an endpoint to receive execution results

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

```mermaid theme={null}
sequenceDiagram
    participant App as Your App
    participant Doclo as Doclo Cloud
    participant Handler as Webhook Handler

    App->>Doclo: Start async execution
    Doclo-->>App: Return execution ID
    Note over Doclo: Processing...
    Doclo->>Handler: POST webhook event
    Handler->>Handler: Verify signature
    Handler->>Handler: Process result
    Handler-->>Doclo: 200 OK
```

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:

```typescript theme={null}
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:

```typescript theme={null}
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:

```typescript theme={null}
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;
  }
}
```

<Warning>
  Always use `timingSafeEqual` for signature comparison. Regular string comparison is vulnerable to timing attacks.
</Warning>

## Next.js Handler

```typescript theme={null}
// 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

```typescript theme={null}
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

```typescript theme={null}
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:

```typescript theme={null}
// 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:

```typescript theme={null}
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

<Tip>
  Return a 200 response quickly, even if you're still processing. Queue the work for async processing if it takes time.
</Tip>

## Async Processing Pattern

For complex processing, acknowledge immediately and process async:

```typescript theme={null}
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:

```typescript theme={null}
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:

```bash theme={null}
# Start your server
npm run dev

# In another terminal, expose port 3000
ngrok http 3000
```

Use the ngrok URL as your webhook URL:

```typescript theme={null}
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

```typescript theme={null}
// Timestamp validation
const event = parseWebhookEvent(body, {
  maxAgeSeconds: 300  // 5 minutes
});

// This throws if the event is too old
```

## Monitoring and Alerting

Track webhook health:

```typescript theme={null}
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](https://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

<CardGroup cols={2}>
  <Card title="Webhooks" icon="webhook" href="/cloud/webhooks">
    Webhook configuration reference
  </Card>

  <Card title="Next.js Integration" icon="react" href="/guides/nextjs-integration">
    Complete Next.js example
  </Card>

  <Card title="Error Recovery" icon="rotate" href="/guides/error-recovery">
    Handle failures gracefully
  </Card>

  <Card title="Polling Results" icon="clock" href="/cloud/polling-results">
    Alternative to webhooks
  </Card>
</CardGroup>
