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
| Event | When it fires |
|---|---|
invoice.parsed | AI extraction completed successfully |
invoice.failed | Extraction failed after all retry attempts |
invoice.approved | A team member approved an invoice in the review UI or via API |
export.completed | A 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.
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:
| Attempt | Delay after previous failure |
|---|---|
| 1 | Immediate |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 1 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.