AAkilIQ
Get an API key

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

EventWhen
job.completedA job finished, with or without warnings.
job.failedA job failed.
dataset.version.createdA new version of a dataset was written.
dataset.publishedA dataset's publication went live on the data API.
anomaly.detectedMonitor found an anomaly in a dataset or a source.
source.failedA source stopped working: its last run failed.
source.recoveredA source that was failing ran successfully again.
sync.completedA destination sync finished.
sync.failedA destination sync failed.
tender.createdA new tender entered the corpus.
tender.updatedA tender in the corpus changed: its dates, value, status or text.
tender.match.createdA tender matched your company profile for the first time.
award.createdA contract award entered the corpus.
record.created, record.updated, record.deletedReserved for record editing, which is not available yet.
webhook.testSent 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

HeaderValue
X-AkilIQ-Signaturet=<unix seconds>,v1=<hex>. One v1 for each live secret.
X-AkilIQ-TimestampThe same t.
X-AkilIQ-Event-IdThe envelope's event_id.
X-AkilIQ-Event-TypeThe envelope's type.
X-AkilIQ-Delivery-IdThis delivery to this endpoint.
X-AkilIQ-AttemptThis 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 attemptWait
130 s
21 min
32 min
44 min
58 min
616 min
732 min
864 min
9128 min
10–193 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.