Skip to the content

Webhooks

Signed events for finished extractions, with retries and a test delivery.

On this page

ReceViz can tell your server when an extraction finishes, instead of your server asking. Each event is a signed HTTPS POST to an endpoint you register on an application, retried until your server acknowledges it.

Events#

Webhook events
EventSent whendata.object
extraction.completedAn extraction finished, whether it was synchronous or asynchronous.The extraction, exactly as GET /v1/extractions/{extraction_id} returns it
extraction.failedAn asynchronous extraction could not be processed. (A synchronous failure is answered to the request itself.)The extraction, with status failed and an error {code, message}
verification.completedVERIFIED mode verified at least one field.{id, object: "verification", document_type, outcomes}, the counts of agreed, disagreed, filled, rejected and unanswered fields
webhook.testYou sent a test delivery from the console.{object: "test", message: "ReceViz webhook test"}

An endpoint receives every event, or only those you choose. An application can have up to five endpoints, and its webhooks need the webhooks.receive capability on the application and its organization: the key that sent a document does not matter. Add endpoints in the console, under Webhooks.

What a delivery looks like#

A delivery is a POST with a JSON body, sent exactly as it was signed: compact, with its keys sorted. Its headers:

ReceViz-Signature

t=<unix seconds>,v1=<hex HMAC-SHA256>. Verify it before anything else.

ReceViz-Event-Id

The event's id, evt_…. The same on every retry of the event and on every endpoint it goes to, so use it to ignore a delivery you already handled.

ReceViz-Event-Type

The event's type, such as extraction.completed.

ReceViz-Delivery-Id

This delivery, whd_…: one per endpoint per event, the same across its retries.

Content-Type

application/json

User-Agent

ReceViz-Webhooks/1.0 (+https://receviz.theaccounthouse.com/docs/webhooks)

The body is an event envelope: id, object (event), type, api_version (v1), created (unix seconds), livemode, application, and data.object. Here with the extraction cut down to the quick start's fields:

json
{
  "id": "evt_Vb3Nq8LkT2xRz5HmP9sWd4",
  "object": "event",
  "type": "extraction.completed",
  "api_version": "v1",
  "created": 1790500446,
  "livemode": false,
  "application": "app_Q3fK8sLm2VxT9pNa4RwZ1c",
  "data": {
    "object": {
      "id": "rv_req_8fK2aQ0zT3mN1pL5vB7xY9",
      "object": "extraction",
      "status": "succeeded",
      "document_type": "payment_receipt",
      "schema_version": 1,
      "mode": "standard",
      "data": {
        "amount": 150,
        "currency": "AED",
        "transaction_date": "2026-09-26",
        "transaction_time": "14:05",
        "receipt_number": "000102",
        "rrn": "626914123456",
        "auth_code": "A1B2C3",
        "terminal_id": "12345678",
        "payment_method": "card",
        "card_scheme": "visa",
        "card_last_four": "4242",
        "approved": true,
        "merchant": "BLUE DHOW CAFE",
        "merchant_address": "DUBAI MARINA WALK"
      },
      "fields": {
        "amount": {
          "value": 150,
          "type": "currency",
          "status": "ok",
          "confidence": 0.906,
          "verified": false,
          "checks": [],
          "source": "rules",
          "page": 1,
          "bounding_box": {
            "x": 0.77,
            "y": 0.7136,
            "width": 0.18,
            "height": 0.05
          },
          "raw_text": "AED 150.00"
        }
      },
      "review": {
        "required": false,
        "reasons": []
      }
    }
  }
}

Verify the signature#

Each endpoint has its own signing secret, rvwhsec_…, shown once when you create the endpoint or rotate its secret. ReceViz stores it encrypted. To check a delivery:

  1. Split ReceViz-Signature on commas into t and v1.
  2. Reject it when t is more than 5 minutes from your clock, either way. The timestamp is inside the signed bytes, so a captured delivery cannot be replayed later.
  3. Compute HMAC-SHA256 with the secret over the bytes <t>.<raw body>: the timestamp, a full stop, then the body exactly as it arrived, before any JSON parsing.
  4. Compare your hex digest with v1 in constant time, and reject the delivery if they differ.
import crypto from "node:crypto";
import express from "express";

const TOLERANCE_SECONDS = 300; // reject deliveries signed more than 5 minutes ago (or ahead)

export function verifyReceVizSignature(rawBody, header, secret, now = Math.floor(Date.now() / 1000)) {
  const parts = {};
  for (const item of (header || "").split(",")) {
    const i = item.indexOf("=");
    if (i > 0) parts[item.slice(0, i).trim()] = item.slice(i + 1).trim();
  }
  const t = Number(parts.t);
  if (!Number.isInteger(t) || !parts.v1) return false;
  if (Math.abs(now - t) > TOLERANCE_SECONDS) return false;

  // HMAC-SHA256 over "<t>.<raw body>", exactly the bytes that arrived.
  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest();
  const given = Buffer.from(parts.v1, "hex");
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}

const app = express();

// express.raw keeps the body as bytes: parse JSON only after the check.
app.post("/webhooks/receviz", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyReceVizSignature(req.body, req.get("ReceViz-Signature"), process.env.RECEVIZ_WEBHOOK_SECRET);
  if (!ok) return res.status(400).send("bad signature");

  const event = JSON.parse(req.body.toString("utf8"));
  res.sendStatus(204); // answer within 10 seconds; do the slow work afterwards
  queue.add(event.id, event); // your own queue; event.id repeats when a delivery is retried
});

Use the raw body

Parsing the JSON and serializing it again changes the bytes, and the signature will not match. Read the body as bytes, verify, then parse.

Respond quickly#

  • Answer with any 2xx within 10 seconds. Anything else is a failed attempt: another status, a timeout, a connection error, or a redirect, since redirects are never followed.
  • Do slow work after answering, from your own queue. A delivery can arrive more than once, for example when your acknowledgement was lost, so handle each ReceViz-Event-Id once.

Retries#

A failed attempt is tried again after a growing pause:

Retry schedule
AttemptSent
1As soon as the event happens
21 minute after attempt 1 failed
35 minutes after attempt 2
430 minutes after attempt 3
52 hours after attempt 4
66 hours after attempt 5
712 hours after attempt 6
824 hours after attempt 7

After the eighth failed attempt the delivery is marked dead. An endpoint that fails 50 attempts in a row is disabled, with the reason shown in the console, so a receiver that is gone stops costing retries; enabling it again resets the count. A disabled endpoint receives nothing.

An event's body is kept only as long as the data policy keeps the result it carries: an hour, 24 hours or 30 days (7 days for extraction.failed). A retry after that sends a stub, {"id", "type", "purged": true}, with no data. See Data and privacy.

Endpoint URLs#

An endpoint URL must:

  • use HTTPS;
  • carry no user name or password;
  • resolve only to public addresses: never loopback, private, link-local (which includes cloud metadata addresses), shared or reserved ranges, and not a localhost, .local or .internal name.

The address is checked when the URL is saved and again before every delivery, because DNS can change. To receive webhooks on a development machine, expose it through a public HTTPS tunnel.

Test deliveries#

In the console, send a test to one endpoint: a webhook.test event goes to that endpoint only, signed like any other. Each endpoint's deliveries are listed with their status (pending, failed, succeeded or dead), the number of attempts, the last status code and error, the response time and when the next attempt is due.

Rotate the secret#

Rotating an endpoint's secret in the console shows the new secret once, and from then on every delivery is signed with it, retries of earlier events included. To switch without rejecting deliveries, let your receiver accept a signature made with either secret until the new one is deployed.