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#
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#
{
"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#
- Read the raw body, byte for byte, before any JSON parsing: parsing and re-serialising changes the bytes and breaks the signature.
- Compute
HMAC-SHA256(secret, X-Timestamp + "." + raw body)and compare"sha256=" + hexwithX-Signaturein constant time. - Reject the request if
X-Timestampis more than 300 seconds away from your clock. - Skip an
X-Event-Idyou already processed. - Answer
2xxwithin 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.
// 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);# Aicscan webhook receiver (Python 3.9+, standard library only).
import hashlib
import hmac
import json
import os
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["AICSCAN_WEBHOOK_SECRET"] # whsec_…
TOLERANCE_SECONDS = 300
seen = set() # in production: a shared table or cache, with expiry
def verify_signature(secret, raw_body, timestamp, signature, now=None):
if not timestamp or not timestamp.isdigit():
return False
now = time.time() if now is None else now
if abs(now - int(timestamp)) > TOLERANCE_SECONDS:
return False
mac = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256)
return hmac.compare_digest("sha256=" + mac.hexdigest(), signature or "")
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
raw = self.rfile.read(int(self.headers.get("Content-Length", 0))) # raw body
if not verify_signature(SECRET, raw, self.headers.get("X-Timestamp"), self.headers.get("X-Signature")):
self.send_response(401)
self.end_headers()
return
event_id = self.headers.get("X-Event-Id")
if event_id not in seen: # a retry can arrive twice
seen.add(event_id)
event = json.loads(raw)
print(event["type"], event["stage"], event["severity"], event["title"])
self.send_response(204)
self.end_headers()
if __name__ == "__main__":
HTTPServer(("", int(os.environ.get("PORT", "3000"))), Handler).serve_forever()<?php
// Aicscan webhook receiver (PHP 8+, no dependency).
declare(strict_types=1);
const TOLERANCE_SECONDS = 300;
function verify_signature(string $secret, string $rawBody, ?string $timestamp, ?string $signature, ?int $now = null): bool
{
if ($timestamp === null || !ctype_digit($timestamp)) {
return false;
}
$now ??= time();
if (abs($now - (int) $timestamp) > TOLERANCE_SECONDS) {
return false;
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature ?? '');
}
$raw = file_get_contents('php://input'); // raw body, before json_decode
$ok = verify_signature(
getenv('AICSCAN_WEBHOOK_SECRET'),
$raw,
$_SERVER['HTTP_X_TIMESTAMP'] ?? null,
$_SERVER['HTTP_X_SIGNATURE'] ?? null
);
if (!$ok) {
http_response_code(401);
exit;
}
$eventId = $_SERVER['HTTP_X_EVENT_ID'] ?? '';
// Skip an $eventId you already processed (e.g. a table with a unique constraint).
$event = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
error_log("{$event['type']} {$event['stage']} {$event['severity']} {$event['title']}");
http_response_code(204);// Aicscan webhook receiver (Go 1.20+, standard library only).
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"
"sync"
"time"
)
const toleranceSeconds = 300
func verifySignature(secret string, rawBody []byte, timestamp, signature string, now time.Time) bool {
ts, err := strconv.ParseUint(timestamp, 10, 64)
if err != nil {
return false
}
if d := now.Unix() - int64(ts); d > toleranceSeconds || d < -toleranceSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
var (
mu sync.Mutex
seen = map[string]bool{} // in production: a shared table or cache, with expiry
)
func handler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // raw body
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
secret := os.Getenv("AICSCAN_WEBHOOK_SECRET")
if !verifySignature(secret, raw, r.Header.Get("X-Timestamp"), r.Header.Get("X-Signature"), time.Now()) {
w.WriteHeader(http.StatusUnauthorized)
return
}
id := r.Header.Get("X-Event-Id")
mu.Lock()
dup := seen[id]
seen[id] = true
mu.Unlock()
if !dup {
var event struct{ Type, Stage, Severity, Title string }
if err := json.Unmarshal(raw, &event); err == nil {
log.Println(event.Type, event.Stage, event.Severity, event.Title)
}
}
w.WriteHeader(http.StatusNoContent)
}
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
http.HandleFunc("/", handler)
log.Fatal(http.ListenAndServe(":"+port, nil))
}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 |
printf '1700000000.{"a":1}' | openssl dgst -sha256 -hmac whsec_test
# SHA2-256(stdin)= 38877139021993b830af32feea6e18a8da83eb2f6e49ee50bd9e4cf4ca4d3789For 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:
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:
httpsonly, no credentials in the URL;- refused names:
localhost,*.localhost,*.local,*.internal,*.lan,*.home.arpaand 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.