Webhooks

A webhook channel sends every notification as a JSON POST to your HTTPS server. Every request is signed with the channel's secret: always verify the signature before processing the content.

To create the channel, see Channels. The secret (whsec_…) is returned only once, at creation; Aicscan stores it encrypted.

Request#

HTTP
POST /aicscan HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
User-Agent: Aicscan-Webhook/1
X-Event-Id: 0193a1c9-41aa-7b2e-9c0d-1e2f3a4b5c6d
X-Timestamp: 1794648613
X-Signature: sha256=7c1e0f6b2a…
Header Content
X-Event-Id Notification id. The same on every retry: use it to skip duplicates.
X-Timestamp Time of this delivery, in Unix seconds (UTC). Changes on every attempt.
X-Signature sha256= followed by the HMAC-SHA256, as lowercase hex, of the string "{X-Timestamp}.{raw body}", computed with the channel's secret.

Body#

JSON
{
  "id": "0193a1c9-41aa-7b2e-9c0d-1e2f3a4b5c6d",
  "type": "alert",
  "stage": "confirmed",
  "severity": "critical",
  "priority": 30,
  "attempt": 1,
  "timestamp": 1794648613,
  "title": "🔴 CRITICAL · Treasury: large USDT outflows · outgoing",
  "text": "🔴 CRITICAL · Treasury: large USDT outflows · outgoing\n✅ Confirmed · 15 confirmations\nChain: BNB Chain\nAddress: Treasury (0x8894…d4e3)\nAmount: 250,000 USDT (≈ $250,000)\nTo: 0x77cd…01e2 (unknown)\nBlock 45,123,456\nDetails: https://aicscan.io/e/0193a1c9-3e02-7f3a-9b4c-5d6e7f8091a2"
}
Field Meaning
id Notification id (equal to X-Event-Id). Full details: GET /v1/notifications/{id}.
type alert, digest, test, security, summary or service (see Alert format).
stage For an alert: unconfirmed, confirmed, cancelled or escalation. For other types, repeats the type.
severity critical, warning or info.
priority Numeric priority (30 and above: critical).
attempt Attempt number (1 on the first delivery).
timestamp Equal to X-Timestamp.
title First line of the message.
text Full message, plain text, in the channel's language.

Ignore fields and values you do not know: new ones can appear (see Versioning).

Verify the signature#

  1. Read the raw body, byte for byte, before any JSON parsing: parsing and re-serialising changes the bytes and breaks the signature.
  2. Compute HMAC-SHA256(secret, X-Timestamp + "." + raw body) and compare "sha256=" + hex with X-Signature in constant time.
  3. Reject the request if X-Timestamp is more than 300 seconds away from your clock.
  4. Skip an X-Event-Id you already processed.
  5. Answer 2xx within 10 seconds.

Use the secret as is, whsec_ prefix included, as the HMAC key (its UTF-8 bytes).

Complete receivers#

Each example starts an HTTP server on port 3000, reads the secret from AICSCAN_WEBHOOK_SECRET and applies the five rules above.

Node.js
// Aicscan webhook receiver (Node.js 18+, no dependency).
import crypto from 'node:crypto';
import http from 'node:http';

const SECRET = process.env.AICSCAN_WEBHOOK_SECRET; // whsec_…, shown when the channel is created
const TOLERANCE_SECONDS = 300;
const seen = new Set(); // in production: a shared table or cache, with expiry

export function verifySignature(secret, rawBody, timestamp, signature, now = Date.now() / 1000) {
  if (typeof timestamp !== 'string' || !/^\d+$/.test(timestamp)) return false;
  if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) return false;
  const expected =
    'sha256=' +
    crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(typeof signature === 'string' ? signature : '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const server = http.createServer((req, res) => {
  if (req.method !== 'POST') return res.writeHead(405).end();
  const chunks = [];
  req.on('data', (c) => chunks.push(c));
  req.on('end', () => {
    const raw = Buffer.concat(chunks); // raw body, before JSON.parse
    const ok = verifySignature(SECRET, raw, req.headers['x-timestamp'], req.headers['x-signature']);
    if (!ok) return res.writeHead(401).end();

    const eventId = req.headers['x-event-id'];
    if (seen.has(eventId)) return res.writeHead(200).end(); // retry already processed
    seen.add(eventId);

    const event = JSON.parse(raw);
    console.log(event.type, event.stage, event.severity, event.title);
    res.writeHead(204).end(); // answer within 10 s; process afterwards if needed
  });
});
server.listen(process.env.PORT ?? 3000);

All four examples were run (Node.js 20, Python 3, PHP 8.3, Go 1.23) against requests signed with openssl: valid request accepted; modified body, modified timestamp, timestamp older than 5 minutes and missing signature refused (401). The Node.js and Python verification functions also accept the reference vector from Aicscan's code below. The PHP example keeps no state between requests and leaves duplicate detection to your database.

Test vector#

To test your implementation without waiting for an alert:

Input Value
Secret whsec_test
X-Timestamp 1700000000
Raw body {"a":1}
Expected X-Signature sha256=38877139021993b830af32feea6e18a8da83eb2f6e49ee50bd9e4cf4ca4d3789
Shell
printf '1700000000.{"a":1}' | openssl dgst -sha256 -hmac whsec_test
# SHA2-256(stdin)= 38877139021993b830af32feea6e18a8da83eb2f6e49ee50bd9e4cf4ca4d3789

For this vector, pass "now" = 1700000000 to your verification function, otherwise the 5-minute window rejects it.

Replay protection#

The timestamp is part of the signed string: an attacker who intercepted a request cannot replay it with a recent timestamp without knowing the secret. By refusing any X-Timestamp more than 300 seconds away from your clock, you limit the replay window to 5 minutes; by remembering processed X-Event-Id values (for at least 24 hours, the retry period), you close it completely.

Keep your server clock in sync (NTP).

Expected response and retries#

Your response Effect
2xx within 10 s Success.
404 or 410 Permanent failure: no more retries for this notification.
429 with Retry-After The channel waits for the requested delay before the next attempt.
3xx Failure: redirects are not followed. Give the final URL.
Any other status, timeout, network error Retried.

Retries follow this schedule: 30 s, 2 min, 10 min, 30 min, 1 h, 3 h, then every 6 h, for no more than 24 hours. Your server's response is read up to 64 KiB; its first 200 characters are kept in the attempt log (GET /v1/notifications/{id}).

Answer fast, then process: put the alert in a queue (database, message broker) and answer 204; a long processing would exceed 10 seconds and trigger a retry.

Automatic disabling#

After 24 hours of continuous failures, the channel is disabled (state: "disabled", reason in disabled_reason) and the workspace owner is notified by email. Once the cause is fixed, enable it again:

Shell
curl -X PATCH https://aicscan.io/v1/channels/$CHANNEL_ID \
  -H "Authorization: Bearer $AICSCAN_API_KEY" -H "Content-Type: application/json" \
  -d '{"state":"active"}'

Then resend the missed notifications with POST /v1/notifications/{id}/resend (list them with GET /v1/notifications?channel_id=…&status=failed).

URL safety#

At creation and at every delivery, the URL is checked:

  • https only, no credentials in the URL;
  • refused names: localhost, *.localhost, *.local, *.internal, *.lan, *.home.arpa and names without a dot;
  • the name is resolved at delivery time; if any of the resulting addresses is private, loopback, link-local (including 169.254.169.254), CGNAT, reserved, multicast or documentation, the delivery is refused;
  • the connection goes to the checked address, with no second DNS resolution and no proxy.

If your receiver filters by source IP address, expect those addresses to change: rely on the signature, not on the address.