akwitoEU VAT proof

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.

MethodRouteRole
GET/v1/webhookcurrent URL, secret prefix and configuration date; 404 NOT_FOUND when unset
POST/v1/webhook/testqueues a signed webhook.test event, 202 Accepted with delivery_id
GET/v1/webhook/deliveriesdelivery 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 #

EventBody
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 requestcarries "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:

HeaderValue
X-Akwito-Eventthe event name
X-Akwito-Timestampsend time, Unix seconds in decimal
X-Akwito-Signaturesha256= 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).