Constaia

Webhooks

Receive signed events from Constaia: each event's body, Standard Webhooks signature verification in seven languages, retries, idempotency and testing.

Constaia notifies you with a POST request to your server when something finishes: an asynchronous analysis, a batch, an analysis that needs human review, or a balance running low. Webhooks follow the Standard Webhooks specification: webhook-id, webhook-timestamp and webhook-signature headers, HMAC-SHA256 signature.

You need them if you use async: true, batches, or if a synchronous analysis takes longer than 30 s and answers 202.

Create an endpoint in the dashboard or with POST /v1/webhook-endpoints. Pick the events and store the whsec_… secret, which is shown only once.

Receive the POST on a public https route of your backend and read the body raw, unparsed.

Verify the signature with the secret. If it is not valid, answer 400 and discard the event.

Answer 2xx right away and process the event in the background. Deduplicate on webhook-id.

Events

EventWhen it is sentdata
analysis.completedAn analysis or classification that is not part of a batch finishes.analysis object (or classification).
analysis.review_requiredAn analysis finishes with a review verdict. Sent in addition to analysis.completed (and also for batch documents).analysis object.
analysis.failedAn analysis fails, including batch ones. It is not charged.analysis object with status: "failed" and error.
batch.completedEvery document of a batch has finished.batch object.
credits.lowThe balance drops below the threshold set in the dashboard. Live mode only, once until you top up.object, credits_available (packs), free_tier_remaining, credits_spendable and threshold.

Every body has the same shape: type, created_at and data. Batch documents do not emit analysis.completed: wait for batch.completed.

reasons and checks messages come in the language the analysis was created with.

Headers

HeaderValue
webhook-idDelivery id, msg_…. It is the same on every retry: use it to deduplicate.
webhook-timestampTime of sending, in Unix seconds. It changes on each retry.
webhook-signaturev1,<base64 signature>. It may carry several space-separated signatures; one match is enough.
content-typeapplication/json
user-agentConstaia-Webhooks/1.0 (+https://constaia.com/docs/webhooks)

How the signature is computed

Strip the whsec_ prefix from the secret and base64-decode the rest. Those bytes are the HMAC key. Do not use the secret text as is.

Build the signed content: {webhook-id}.{webhook-timestamp}.{body}, with the body exactly as received (raw bytes, not parsed and re-serialised).

Compute HMAC-SHA256(key, content) and encode the result in base64 (not hex).

Split webhook-signature on spaces. For each v1,<signature> entry, compare <signature> with yours in constant time. If none matches, reject.

Also reject if webhook-timestamp is more than 5 minutes (300 s) away from your clock. This prevents replay attacks.

Verify with the SDKs

The SDKs do all five steps and return the parsed event, or throw an error if anything is off.

server.ts
import express from "express";
import { Constaia, WebhookVerificationError, type WebhookEvent } from "@constaia/sdk";

const app = express();
const constaia = new Constaia();
const secret = process.env.CONSTAIA_WEBHOOK_SECRET!;

// express.raw: the body arrives as a Buffer, unparsed
app.post("/webhooks/constaia", express.raw({ type: "application/json" }), async (req, res) => {
  let event: WebhookEvent<any>;
  try {
    event = await constaia.webhooks.verify(req.body, req.headers, secret);
  } catch (err) {
    const status = err instanceof WebhookVerificationError ? 400 : 500;
    return res.sendStatus(status);
  }

  res.sendStatus(204);
  void handleEvent(req.header("webhook-id")!, event).catch(console.error);
});

async function handleEvent(deliveryId: string, event: WebhookEvent<any>) {
  // Deduplicate on deliveryId in your database before processing
  switch (event.type) {
    case "analysis.completed":
      console.log(deliveryId, event.data.id, event.data.verdict?.status);
      break;
    case "analysis.review_required":
      console.log("To review:", event.data.id);
      break;
    case "analysis.failed":
      console.log("Failed:", event.data.id, event.data.error?.code);
      break;
    case "batch.completed":
      console.log("Batch finished:", event.data.id, event.data.counts);
      break;
    case "credits.low":
      console.log("Low balance:", event.data.credits_available);
      break;
  }
}

app.listen(3000);

verifyWebhook(payload, headers, secret, { tolerance }) does the same without instantiating the client: import { verifyWebhook } from "@constaia/sdk".

Verify without an SDK

Complete implementations of the algorithm. They all take the raw body.

verify-constaia.ts
import { createHmac, timingSafeEqual } from "node:crypto";

type Headers = Record<string, string | string[] | undefined>;

export function verifyConstaiaWebhook(rawBody: Buffer | string, headers: Headers, secret: string, tolerance = 300) {
  const get = (name: string) => {
    const value = headers[name];
    return Array.isArray(value) ? value[0] : value;
  };
  const id = get("webhook-id");
  const timestamp = get("webhook-timestamp");
  const signatures = get("webhook-signature");
  if (!id || !timestamp || !signatures) throw new Error("Missing webhook headers");

  const ts = Number(timestamp);
  if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > tolerance) {
    throw new Error("webhook-timestamp outside tolerance");
  }

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = Buffer.from(
    createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest("base64"),
  );

  const valid = signatures.split(" ").some((entry) => {
    const [version, signature] = entry.split(",");
    if (version !== "v1" || !signature) return false;
    const received = Buffer.from(signature);
    return received.length === expected.length && timingSafeEqual(received, expected);
  });
  if (!valid) throw new Error("Invalid signature");

  return JSON.parse(rawBody.toString());
}

IncomingMessage headers (Node, Express) are already lowercase.

The raw body, framework by framework

The most common mistake is verifying a body your framework has already parsed and re-serialised: whitespace, order or escaping change and the signature stops matching. Always read the original bytes:

FrameworkHow to read the raw body
Expressexpress.raw({ type: "application/json" }) on the webhook route. If you use a global app.use(express.json()), register the webhook route before it.
Next.js (App Router)const raw = await req.text() in route.ts. Do not call req.json() first.
Next.js (Pages Router)export const config = { api: { bodyParser: false } } and read the req stream.
Fastify / HonoFastify: an addContentTypeParser with parseAs: "buffer" inside a plugin that only contains the webhook route. Hono: await c.req.text().
Laravel$request->getContent(), never $request->all(). Put the route in routes/api.php or exclude it from CSRF.
Symfony$request->getContent().
Djangorequest.body, with @csrf_exempt on the view.
Flask / FastAPIrequest.get_data() / await request.body().
Railsrequest.raw_post, in a controller without protect_from_forgery.
Spring Boot@RequestBody byte[] body.
ASP.NET CoreCopy Request.Body into a MemoryStream before any binding.

Guides with the full webhook route: Express, Next.js, Laravel, Django.

Deliveries and retries

  • A delivery succeeds if your server answers any 2xx within 15 seconds. Redirects are not followed and count as a failure, just like a 4xx, a 5xx or a timeout.
  • If it fails, it is retried after: 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h, 12 h, 12 h, 10 h and 10 h. That is 12 attempts over about 3 days. After that the delivery is marked failed.
  • If you disable or delete the endpoint, its pending deliveries are marked as failed.
  • The dashboard shows the last 100 deliveries of each endpoint with their response code.

Good practices

  • Answer fast. Verify, store the event or put it in a queue, answer 204 and process afterwards. If your logic takes longer than 15 s, Constaia counts it as a failure and retries.
  • Be idempotent. The same event can arrive more than once (for example, if you answered late). Store the processed webhook-id values and discard repeats.
  • Do not rely on order. With retries, an old event can arrive after a newer one. If you need the current state, call GET /v1/analyses/{id}.
  • Treat data as the source. The event carries the full object; you do not need another call except to refresh it.
  • Protect the exports URLs. They expire after 24 h and need no key: do not write them to public logs.

Testing your webhooks

Test mode

Endpoints created with a ck_test_ key receive events from analyses made with test keys. That way you test the whole flow for free: analyse dni_valid.jpg with async: true and you will receive analysis.completed; blurry.jpg also triggers analysis.review_required. See Test mode. To receive them on your machine, expose your local server with an https tunnel.

Test event

From the dashboard you can send a type: "test" event to any endpoint. It is signed like real ones, sent once, and is useful to check the URL and your verification.

Signing your own payloads

For automated tests, the SDKs generate valid headers for a payload and a secret:

webhook.test.ts
import { signWebhook } from "@constaia/sdk";

const secret = process.env.CONSTAIA_WEBHOOK_SECRET!;
const payload = JSON.stringify({
  type: "analysis.completed",
  created_at: new Date().toISOString(),
  data: { id: "an_test", object: "analysis", status: "completed", verdict: null, metadata: {} },
});

const headers = await signWebhook(payload, secret, { id: "msg_test_1" });
const res = await fetch("http://localhost:3000/webhooks/constaia", {
  method: "POST",
  headers: { ...headers, "content-type": "application/json" },
  body: payload,
});
console.log(res.status); // 204

Use the secret of a test endpoint. Never put the production secret in tests or repositories.

Next steps

On this page