Skip to content

Webhooks

Webhooks notify your system when something happens, so you don’t have to poll. Available on the Scale and Enterprise plans.

Event Sent when
invoice.analysis.completed An invoice has a decision.
invoice.analysis.failed Analysis of a document failed.
scan.completed A historical scan finished.
finding.created A new finding was raised.
finding.reviewed Someone recorded a disposition on a finding.

In Integrations → Webhooks, enter your endpoint URL (HTTPS only) and pick the events, or use the API:

Terminal window
curl https://api.invogi.com/v1/webhooks \
-H "Authorization: Bearer $INVOGI_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/invogi/webhooks", "events": ["invoice.analysis.completed"]}'

The response includes the endpoint’s signing secret. It is shown once, so store it straight away; you need it to verify deliveries.

{
"id": "evt_…",
"type": "invoice.analysis.completed",
"data": {
"invoice_id": "3f2c…",
"decision": "PASS",
"risk_score": 4,
"export_ready": true,
"canonical_export_path": "/v1/invoices/3f2c…/export?format=json"
}
}

export_ready is true only for PASS decisions; use canonical_export_path to fetch the invoice for your payment system.

Every delivery carries two headers:

  • invogi-event-id: the event ID.
  • invogi-signature: t=<unix seconds>,v1=<hex>.

The signature is HMAC-SHA256, keyed with your endpoint secret, over <event id>.<t>.<raw request body>. Reject the request if no v1 value matches or if t is more than a few minutes old.

import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyInvogiWebhook(rawBody: string, headers: Headers, secret: string): boolean {
const eventId = headers.get("invogi-event-id") ?? "";
const header = headers.get("invogi-signature") ?? "";
const match = header.match(/^t=(\d+)((?:,v1=[a-f0-9]+)+)$/);
if (!match) return false;
const timestamp = Number(match[1]);
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = createHmac("sha256", secret).update(`${eventId}.${timestamp}.${rawBody}`).digest();
return (match[2] ?? "")
.split(",")
.slice(1)
.map((entry) => Buffer.from(entry.slice(3), "hex"))
.some((candidate) => candidate.length === expected.length && timingSafeEqual(candidate, expected));
}

Always verify against the raw body, before parsing JSON.

Rotate secret in the app (or POST /v1/webhooks/{id}/rotate-secret) returns a new secret once. For 24 hours deliveries are signed with both secrets, so the header carries two v1= values and the code above accepts either. Rotating again within that window drops the older secret immediately.

Respond with any 2xx status within 15 seconds. Failed deliveries are retried with exponential backoff and stop after five failed attempts.

Integrations → Webhooks → Recent deliveries lists the latest 100 deliveries with status, response code and attempt count, and a Replay button for failed ones. Use invogi-event-id to ignore duplicates, since a replay re-sends the same event.