Documentation

Webhooks guide

Receive real-time invoice events, verify signatures, and handle retries.

Overview

Webhooks deliver HTTP POST requests to your endpoint the moment an event occurs — no polling required. Configure endpoints from the Webhooks dashboard. Each workspace can have multiple endpoints subscribed to different event types.

Supported events

EventWhen it fires
invoice.parsedAI extraction completed successfully
invoice.failedExtraction failed after all retry attempts
invoice.approvedA team member approved an invoice in the review UI or via API
export.completedA bulk export file is ready for download

Payload structure

Every delivery shares the same envelope:

{
  "event": "invoice.parsed",
  "timestamp": "2024-11-15T14:23:01.000Z",
  "data": {
    "invoiceId": "6741a2f3e4b0c1d2e3f4a5b6",
    "workspaceId": "6741a2f3e4b0c1d2e3f4a5b7",
    "provider": "openai",
    "durationMs": 3420
  }
}

The data object content varies by event type. Use the invoiceId to fetch the full parsed invoice via the API if you need the extracted fields.

Verifying signatures

Every webhook delivery includes an X-Signature header containing an HMAC-SHA256 digest of the raw request body, signed with your endpoint's signing secret. Signature verification is mandatory — reject any request that fails this check before processing the payload.

Header format
X-Signature: sha256=a3f1b2c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2

Important — read the body as raw bytes

JSON pretty-printing or any re-serialization will break the signature. Read the body as a raw string and parse it only after verification.

Node.js / TypeScript example:

import { createHmac, timingSafeEqual } from 'node:crypto';

/**
 * Returns true only if the request body matches the expected signature.
 * Uses timingSafeEqual to prevent timing-based attacks.
 */
function verifyWebhookSignature(
  rawBody: string,
  signatureHeader: string,
  signingSecret: string
): boolean {
  const digest = createHmac('sha256', signingSecret)
    .update(rawBody, 'utf8')
    .digest('hex');

  const expected = Buffer.from(`sha256=${digest}`, 'utf8');
  const received = Buffer.from(signatureHeader, 'utf8');

  // Length check prevents Buffer.compare from throwing on mismatched lengths
  if (expected.length !== received.length) return false;
  return timingSafeEqual(expected, received);
}

// Next.js App Router handler example
export async function POST(req: Request) {
  const rawBody = await req.text();
  const signature = req.headers.get('x-signature') ?? '';

  if (!verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET!)) {
    return Response.json({ error: 'Invalid signature' }, { status: 401 });
  }

  const payload = JSON.parse(rawBody);
  // safe to process payload here
}

Signing secrets are scoped to each webhook endpoint and can be rotated from the Webhooks settings page at any time. After rotation, both the old and new secrets are valid for a 10-minute overlap to allow a zero-downtime rollover.

Retries and delivery logs

A delivery is considered successful when your endpoint returns an HTTP 2xx response within 10 seconds. Failed deliveries are retried up to 4 times with exponential backoff:

AttemptDelay after previous failure
1Immediate
230 seconds
35 minutes
41 hour
5 (final)1 hour

View the full delivery log — including request body, response status, and latency — from the Webhooks dashboard by expanding any endpoint row.

Best practices

  • Always verify the X-Signature before processing — do not skip this in production.
  • Respond with 2xx immediately and process the payload asynchronously to avoid timeouts.
  • Make your handler idempotent — deliveries may occasionally arrive more than once.
  • Store the X-Delivery-Id header to deduplicate retries.
  • Use the "Send test" button in the dashboard to confirm your endpoint is reachable before going live.