Skip to content
Aranisdevelopers
Sign in

Webhooks

Four events, signed the same way Stripe signs theirs.

Events

EventFires when
assessment.completedAn assessment transitions into completed. Once — re-saving does not re-emit.
report.readyA report's PDF becomes available, whether at creation or when rendered later.
alert.createdA supplier or organizational alert is raised. data.scope tells them apart.
action_plan_item.status_changedAn 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

POST to your endpoint
{
  "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

Header
Aranis-Signature: t=1753876800,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Compute HMAC-SHA256("{t}.{raw_body}", secret) and compare to v1 in constant time.

Use the raw request body. Re-serializing parsed JSON changes key order and whitespace, and the signature will never match. In Express use express.raw() on that route; in Next.js App Router use await request.text().
TypeScript
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.

Timeout is 5 seconds. Acknowledge first, process afterwards — a slow handler is recorded as a failed delivery, and ten consecutive failures disable the endpoint and notify your workspace admins.

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
curl -s "$ARANIS/webhook-deliveries?status=failed&limit=20" \
  -H "Authorization: Bearer $ARANIS_API_KEY" | jq '.data[] | {event_type, attempt, response_status, error}'