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
| Event | When it is sent | data |
|---|---|---|
analysis.completed | An analysis or classification that is not part of a batch finishes. | analysis object (or classification). |
analysis.review_required | An analysis finishes with a review verdict. Sent in addition to analysis.completed (and also for batch documents). | analysis object. |
analysis.failed | An analysis fails, including batch ones. It is not charged. | analysis object with status: "failed" and error. |
batch.completed | Every document of a batch has finished. | batch object. |
credits.low | The 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
| Header | Value |
|---|---|
webhook-id | Delivery id, msg_…. It is the same on every retry: use it to deduplicate. |
webhook-timestamp | Time of sending, in Unix seconds. It changes on each retry. |
webhook-signature | v1,<base64 signature>. It may carry several space-separated signatures; one match is enough. |
content-type | application/json |
user-agent | Constaia-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.
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.
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:
| Framework | How to read the raw body |
|---|---|
| Express | express.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 / Hono | Fastify: 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(). |
| Django | request.body, with @csrf_exempt on the view. |
| Flask / FastAPI | request.get_data() / await request.body(). |
| Rails | request.raw_post, in a controller without protect_from_forgery. |
| Spring Boot | @RequestBody byte[] body. |
| ASP.NET Core | Copy 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
2xxwithin 15 seconds. Redirects are not followed and count as a failure, just like a4xx, a5xxor 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
204and 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-idvalues 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
dataas the source. The event carries the full object; you do not need another call except to refresh it. - Protect the
exportsURLs. 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:
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); // 204Use the secret of a test endpoint. Never put the production secret in tests or repositories.
Next steps
Webhook endpoints
Reference for /v1/webhook-endpoints: create, list, retrieve and delete the URLs that receive Constaia events, with their whsec_ secret and mode.
Verdicts and reasons
How the valid, invalid or review verdict is computed from reason severities, the full table of stable reason codes and what to do in each case.