Webhooks
Four events, signed the same way Stripe signs theirs.
Events
| Event | Fires when |
|---|---|
assessment.completed | An assessment transitions into completed. Once — re-saving does not re-emit. |
report.ready | A report's PDF becomes available, whether at creation or when rendered later. |
alert.created | A supplier or organizational alert is raised. data.scope tells them apart. |
action_plan_item.status_changed | An item's status actually changes. |
Registering an endpoint
Endpoints are registered in the Aranis app under Settings → Integrations, never through the API. That is deliberate: a key that could register an endpoint could redirect every future event to an address you never chose, turning a leaked read-scoped key into a standing exfiltration channel.
The signing secret is shown once, at registration. The URL must be https://.
The payload is thin on purpose
{
"event_id": "8f14e45f-ceea-467a-9f0b-2c1d3e4f5a6b",
"type": "assessment.completed",
"occurred_at": "2026-07-30T18:22:11Z",
"organization_id": "93a4f274-3f39-4f7e-9d83-1f0988e1285a",
"data": {
"assessment_id": "496283fa-520a-4fc1-9982-1a0c44fa3543",
"supplier_id": "3f1a8c2e-9b47-4d51-a0e6-2c7d8f4b1a93",
"status": "completed"
}
}Ids and the minimum state — you fetch the full resource through the API. Three reasons: nothing sensitive lands in your endpoint's logs, the personal-data rules keep applying at read time (so a payload cannot leak what your key may not see), and the event shape does not change when a resource gains a field.
Verifying the signature
Aranis-Signature: t=1753876800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdCompute HMAC-SHA256("{t}.{raw_body}", secret) and compare to v1 in constant time.
express.raw() on that route; in Next.js App Router use await request.text().import { verifyWebhook, WebhookVerificationError } from '@aranis/api'
try {
const event = verifyWebhook({
rawBody,
signatureHeader: req.header('Aranis-Signature'),
secret: process.env.ARANIS_WEBHOOK_SECRET!,
})
} catch (err) {
if (err instanceof WebhookVerificationError) return res.status(400).send('invalid')
throw err
}Reject anything whose t is more than 5 minutes old. The timestamp is inside the signed payload, so a captured delivery cannot be replayed later — moving t forward invalidates the HMAC.
The scheme is byte-for-byte Stripe's. If you already verify Stripe webhooks, change the header name and you are done.
Delivery, retries and duplicates
Delivery is at-least-once. event_id is stable across every retry and across the fan-out to multiple endpoints — deduplicate on it, in Redis or a table with a unique index.
Respond 2xx to acknowledge. Anything else is a failure and is retried after 1 min, 5 min, 30 min, 2 h and 6 h. After the fifth attempt the delivery is marked exhausted.
Return 400 for a bad signature, not 500. A 5xx makes us retry five times something that will never verify.
When it goes quiet
GET /v1/webhook-deliveries returns every attempt with the response we saw — status code, body snippet, error, and when the next retry is scheduled. Filter by endpoint_id or status=failed. It requires webhooks:read.
curl -s "$ARANIS/webhook-deliveries?status=failed&limit=20" \
-H "Authorization: Bearer $ARANIS_API_KEY" | jq '.data[] | {event_type, attempt, response_status, error}'