Documentation / Webhooks
Webhooks
An account can register one HTTPS endpoint. akwito notifies it when a background request completes or is abandoned, when a batch finishes, and when a watched number changes — see the event table below.
| Method | Route | Role |
|---|---|---|
| GET | /v1/webhook | current URL, secret prefix and configuration date; 404 NOT_FOUND when unset |
| POST | /v1/webhook/test | queues a signed webhook.test event, 202 Accepted with delivery_id |
| GET | /v1/webhook/deliveries | delivery log, newest first, limit and cursor as above |
The endpoint is configured from the dashboard (/app/settings, section Webhook), not through the API: an API key reads the configuration, sends a test event and lists deliveries, it never changes the account's settings. The URL must be https and must resolve to a publicly routable address: loopback, private, link-local, CGNAT and unique-local addresses are refused at configuration time and again before every delivery.
The secret is shown once, in the dashboard, when the URL is saved — only its first 8 characters are readable afterwards. Saving the URL again rotates the secret and invalidates the previous one immediately.
Events #
| Event | Body |
|---|---|
check.completed | {"event", "request_id", "check"} where check is the check object |
check.failed | {"event", "request_id", "reason"}, plus last_error when one was recorded |
batch.completed | {"event", "batch_id", "total", "done", "failed"}, sent once a batch has no line left to process, whatever its outcome |
watch.changed | {"event", "watch_id", "vat_number", "label", "kind", "before", "after", "check_id"}, sent once per change observed on a watched number, never once per run |
webhook.test | {"event", "sent_at"} |
any check.* event of a test request | carries "test": true at the top level; route on it |
The check embedded in check.completed carries the same values as GET /v1/checks/{id}, but not necessarily the same bytes: the delivery payload is stored as JSON, so key order and whitespace may differ. A response_hash is therefore always verified against the body returned by GET /v1/checks/{id}, never against the webhook body.
Signature #
Each delivery is a POST with these headers:
| Header | Value |
|---|---|
X-Akwito-Event | the event name |
X-Akwito-Timestamp | send time, Unix seconds in decimal |
X-Akwito-Signature | sha256= followed by the lowercase hex HMAC-SHA256 |
The signed message is the timestamp, a literal dot, then the raw request body:
signature = "sha256=" + hex(hmac_sha256(secret, timestamp + "." + body))
Verify it before trusting the payload, comparing in constant time, and reject a timestamp that is more than five minutes away from your own clock:
expected = "sha256=" + hex(hmac_sha256(secret, header_timestamp + "." + raw_body))
accept = constant_time_equals(expected, header_signature)
and abs(now - header_timestamp) <= 300
A delivery is considered successful on any 2xx. Otherwise it is retried five times, after 30 seconds, 2, 10, 30 and 60 minutes, then marked failed. The timeout of a single attempt is 10 seconds by default (WEBHOOK_TIMEOUT).