Guide
Webhooks
AkilIQ sends a signed HTTPS POST to your endpoint when something happens, and keeps trying for more than 24 hours if your endpoint does not answer.
Endpoints
Owners and administrators add endpoints on the API page, under Webhooks, and choose the events each one receives.
The URL must use HTTPS and reach a public address. Redirects are not followed.
The signing secret is shown once, when the endpoint is added or its secret is rotated. Store it as a secret.
Events
| Event | When |
|---|---|
job.completed | A job finished, with or without warnings. |
job.failed | A job failed. |
dataset.version.created | A new version of a dataset was written. |
dataset.published | A dataset's publication went live on the data API. |
anomaly.detected | Monitor found an anomaly in a dataset or a source. |
source.failed | A source stopped working: its last run failed. |
source.recovered | A source that was failing ran successfully again. |
sync.completed | A destination sync finished. |
sync.failed | A destination sync failed. |
tender.created | A new tender entered the corpus. |
tender.updated | A tender in the corpus changed: its dates, value, status or text. |
tender.match.created | A tender matched your company profile for the first time. |
award.created | A contract award entered the corpus. |
record.created, record.updated, record.deleted | Reserved for record editing, which is not available yet. |
webhook.test | Sent by Send test event, to that endpoint only. |
Tender and award events are about the shared corpus: every subscribing organisation receives the same event, with the same event_id.
The envelope
Every delivery has this shape. The data object depends on the type.
{
"event_id": "evt_01J9ZQ3M4N5P6R7S8T9V0W1X2Y",
"type": "job.completed",
"occurred_at": "2026-09-28T10:00:00Z",
"organization_id": "org_01J9ZQ3M4N5P6R7S8T9V0W1X2Y",
"workspace_id": "ws_01J9ZQ3M4N5P6R7S8T9V0W1X2Y",
"correlation_id": null,
"api_version": "v1",
"attempt": 1,
"data": { "job_id": "job_01J9ZQ3M4N5P6R7S8T9V0W1X2Y", "status": "completed" }
}Verifying the signature
| Header | Value |
|---|---|
X-AkilIQ-Signature | t=<unix seconds>,v1=<hex>. One v1 for each live secret. |
X-AkilIQ-Timestamp | The same t. |
X-AkilIQ-Event-Id | The envelope's event_id. |
X-AkilIQ-Event-Type | The envelope's type. |
X-AkilIQ-Delivery-Id | This delivery to this endpoint. |
X-AkilIQ-Attempt | This try, from 1. |
signed_payload = "<t>" + "." + <raw request body>
v1 = hex( HMAC-SHA256( key = your secret, "whsec_…", as UTF-8,
message = signed_payload ) )Verify before you parse, over the exact bytes you received. Compare in constant time, and reject a timestamp more than five minutes from your clock.
After a rotation, deliveries carry a signature for the new secret and one for the old, until the overlap you chose ends. Accept a request if any signature matches.
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
"""True if an AkilIQ webhook is authentic and recent.
raw_body: the request body exactly as received, before any JSON parsing.
signature_header: the X-AkilIQ-Signature header, "t=<unix>,v1=<hex>[,v1=<hex>]".
secret: the endpoint's signing secret, "whsec_...".
"""
timestamp, signatures = None, []
for part in signature_header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1":
signatures.append(value)
if timestamp is None or not signatures:
return False
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False # too old (or too far ahead): a replayed or delayed request
signed = str(timestamp).encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, candidate) for candidate in signatures)
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
// rawBody: the request body exactly as received (a Buffer), before JSON parsing.
// signatureHeader: the X-AkilIQ-Signature header, "t=<unix>,v1=<hex>[,v1=<hex>]".
// secret: the endpoint's signing secret, "whsec_...".
export function verifyWebhook(rawBody, signatureHeader, secret) {
let timestamp = null;
const signatures = [];
for (const part of signatureHeader.split(",")) {
const [key, value = ""] = part.trim().split("=", 2);
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === "v1") signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
return signatures.some((candidate) => {
const given = Buffer.from(candidate, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
Delivery and retries
Answer with any 2xx status within ten seconds. Anything else, a redirect included, is a failed attempt, and the delivery is tried again later.
| After failed attempt | Wait |
|---|---|
1 | 30 s |
2 | 1 min |
3 | 2 min |
4 | 4 min |
5 | 8 min |
6 | 16 min |
7 | 32 min |
8 | 64 min |
9 | 128 min |
10–19 | 3 h |
Twenty attempts over more than 34 hours. Each wait can be up to a fifth longer, never shorter.
Duplicates and ordering
Delivery is at least once: the same event can arrive more than once. Deduplicate on event_id, which never changes, even on a replay.
Ordering is not guaranteed. An event can arrive before one that happened earlier, so compare occurred_at rather than arrival order.
Dead letters and replay
After the twentieth failed attempt the delivery becomes a dead letter. The endpoint's delivery log shows every attempt, with the status and the start of the response.
Replay one dead letter, or up to 1,000 at a time, from the log. A replay keeps the event_id and is signed with the current secret.
An endpoint that has failed every delivery for 72 hours is disabled. The person who added it is told in the notification bell and by email, as their notification settings allow; if they are no longer an owner or administrator, every owner and administrator is told. Its outstanding deliveries become dead letters, ready to replay once it is fixed and enabled.