Webhook signature verification
Every webhook request ChainBridge sends carries a HMAC-SHA256 signature and a timestamp. Check both the signature and the timestamp skew. That is how you know a request really came from ChainBridge and is not a replay.
Headers
| Headers | Example | Description |
|---|---|---|
| X-CS-Event | approval.created | Event type |
| X-CS-Delivery | dlv_01J9Y7Q2 | Delivery ID. The same value survives retries and re-deliveries, so use it to deduplicate. |
| X-CS-Timestamp | 1761408000 | Unix timestamp in seconds, used for replay protection |
| X-CS-Signature | sha256=9f2c… | HMAC-SHA256 signature:sha256= prefix followed by lowercase hex |
| Content-Type | application/json | The body is JSON |
Signature algorithm
signed_payload = timestamp + "." + raw_body signature = HMAC-SHA256(webhook_secret, signed_payload) # use a constant-time comparison (timing-safe); never compare the strings with == # header format: X-CS-Signature: sha256=
The signature covers the raw bytes of the HTTP body. If you deserialize and re-serialize the JSON first, whitespace, key order, and escaping all change. The signature check will always fail. Parse the JSON only after verification succeeds.
Verification steps
Code examples
const crypto = require('crypto');
const express = require('express');
const app = express();
// key point: verify the signature against the raw body; do not run express.json() first
app.post('/chainshield/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const secret = process.env.CS_WEBHOOK_SECRET; // whsec_xxx — server side only
const timestamp = req.header('X-CS-Timestamp') || '';
const signature = req.header('X-CS-Signature') || '';
const rawBody = req.body.toString('utf8');
// 1) timestamp skew ≤ 300 seconds
const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!timestamp || Number.isNaN(skew) || skew > 300) {
return res.status(400).json({ ok: false, message: 'timestamp outside the allowed window (±300 seconds)' });
}
// 2) HMAC-SHA256 signature
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signature);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).json({ ok: false, message: 'signature verification failed' });
}
// 3) dedupe: handle the same endpoint_id + event_id only once
const event = JSON.parse(rawBody);
// example: check those keys for duplicates before running business logic
// 4) return 2xx only after processing completes
res.status(200).json({ ok: true });
});
app.listen(3000);
import hmac
import hashlib
import os
import time
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET = os.environ["CS_WEBHOOK_SECRET"].encode() # whsec_xxx — server side only
@app.post("/chainshield/webhook")
def webhook():
raw_body = request.get_data() # raw bytes; do not use request.json
timestamp = request.headers.get("X-CS-Timestamp", "")
signature = request.headers.get("X-CS-Signature", "")
# 1) timestamp skew ≤ 300 seconds
try:
skew = abs(int(time.time()) - int(timestamp))
except (TypeError, ValueError):
return jsonify(ok=False, message="missing or invalid timestamp"), 400
if skew > 300:
return jsonify(ok=False, message="timestamp outside the allowed window (±300 seconds)"), 400
# 2) HMAC-SHA256 signature (constant-time comparison)
signed_payload = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(SECRET, signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return jsonify(ok=False, message="signature verification failed"), 401
# 3) dedupe by endpoint_id + event_id before returning 2xx
return jsonify(ok=True), 200
<?php
$secret = getenv('CS_WEBHOOK_SECRET'); // whsec_xxx — server side only
$rawBody = file_get_contents('php://input'); // raw request body
$timestamp = $_SERVER['HTTP_X_CS_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_CS_SIGNATURE'] ?? '';
// 1) timestamp skew ≤ 300 seconds
if ($timestamp === '' || abs(time() - (int)$timestamp) > 300) {
http_response_code(400);
echo json_encode(['ok' => false, 'message' => 'timestamp outside the allowed window (±300 seconds)']);
exit;
}
// 2) HMAC-SHA256 signature
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, $signature)) { // hash_equals is a constant-time comparison
http_response_code(401);
echo json_encode(['ok' => false, 'message' => 'signature verification failed']);
exit;
}
// 3) dedupe, then return 2xx after processing
http_response_code(200);
echo json_encode(['ok' => true]);
Retry backoff and dead letters
Failed deliveries retry with exponential backoff, six times in total. A delivery that still fails lands in the dead-letter queue for inspection and manual re-delivery in the console.
| Attempt | Interval | Cumulative time (approx.) | Description |
|---|---|---|---|
| 1 | 1 minute | 1 minute | First retry, reusing the same X-CS-Delivery |
| 2 | 5 minutes | 6 minutes | — |
| 3 | 30 minutes | 36 minutes | — |
| 4 | 2 hours | about 2.6 hours | — |
| 5 | 6 hours | about 8.6 hours | — |
| 6 | 24 hours | about 32.6 hours | Final attempt |
| — | — | — | After 6 attempts it enters the dead-letter queue, kept 30 days for manual re-delivery. |
When a delivery is retried
- · 响应 5xx、连接失败、TLS 失败、响应超时(> 5 秒)→ 按退避重试
- · 4xx response (such as 404 or 401) → permanent failure, goes straight to the dead-letter queue
- · 3xx redirect → treated as a configuration error and sent straight to the dead-letter queue
Deduplication and billing
- · Dedupe key:endpoint_id + event_id
- · Retries and re-deliveries reuse the same X-CS-Delivery, which receivers use to deduplicate
- · Only successfully delivered events are billed; retries are not charged again
Event type
| Event | Trigger |
|---|---|
| approval.created | A new token approval is detected |
| approval.changed | The allowance changes |
| approval.revoked | A revocation completes and is confirmed onchain |
| revoke.failed | A revocation transaction fails (with the reason) |
| risk.label.updated | The risk label of an address or contract changes |
| incident.published | An incident is published |
| incident.corrected | An incident is corrected |
| monitor.alert | A monitoring rule matches and raises an alert |
| usage.quota.warning | Usage approaches the plan quota |
| invoice.issued | An invoice is issued |
Search this page
Enter a keyword to search headers, event types, error codes, and troubleshooting entries.
Troubleshooting
Most often, in this order: ① you hashed re-serialized JSON instead of the raw body; ② you left out the timestamp + "." prefix or the dot when concatenating; ③ you included the sha256= prefix in the hex comparison; ④ the secret is the pre-rotation value. The delivery log on the Webhooks page shows the response code and latency. That separates a verification failure from a network problem.
400601 means webhook signature verification failed (including a timestamp out of range). Check the receiving server's clock sync (NTP is recommended) and confirm no proxy rewrites the request body.
Returns 400600(webhook endpoint unreachable). Check the URL, DNS, TLS certificate, and firewall allowlist. Repeated failures raise an alert, and the event is retried with backoff until it lands in the dead-letter queue.
Yes. Use X-CS-Event to route them. Splitting endpoints by business domain (approvals, billing) keeps a slow handler from blocking the rest.
Yes: ChainBridge must be able to reach your receiving address (it must be https://). If inbound traffic is not allowed, poll the Data API instead, or use the self-hosted gateway's internal event channel.