Webhook Signature Verification¶
To ensure that incoming webhook payloads originate exclusively from Abelo and have not been tampered with or intercepted in transit, every webhook HTTP POST request is signed using cryptographic HMAC-SHA256.
1. Delivery Headers¶
Abelo sends the following headers with every webhook dispatch:
| Header | Description | Example |
|---|---|---|
X-Abelo-Signature |
The cryptographic HMAC-SHA256 signature with sha256= prefix |
sha256=4f3b7d1e8a9c... |
X-Abelo-Timestamp |
Unix timestamp in seconds (UTC) when the dispatch occurred | 1724164200 |
X-Abelo-Topic |
The event topic string | message.delivered |
X-Abelo-Delivery-Id |
Unique UUID for this delivery attempt (use for deduplication) | 0191636f-bcf0-7813-9f89-8d7681728271 |
X-Abelo-Retry-Attempt |
Delivery attempt sequence count (0 for initial attempt) |
0 |
2. How Signatures are Computed¶
- Abelo constructs a canonical string combining the timestamp and the raw UTF-8 JSON request body:
- Computes the HMAC-SHA256 hash using your secret (
signing_secretfor org webhooks orwebhook_signing_secretfor per-message webhooks). - Prepends
sha256=to the hex digest and sends it in theX-Abelo-Signatureheader.
3. Verification Algorithm¶
To verify an incoming webhook:
- Extract Headers: Read
X-Abelo-TimestampandX-Abelo-Signature. - Replay Protection: Verify that the timestamp is within 300 seconds (5 minutes) of your server time.
- Construct Canonical Message: Compute
f"{timestamp}.{raw_request_body}"using the raw, unparsed request bytes. - Compute Expected Signature: Calculate
sha256=+HMAC_SHA256(secret, canonical_message). - Constant-Time Comparison: Use
hmac.compare_digestorcrypto.timingSafeEqualto verify the signatures match.
4. Complete Verification Examples¶
Python (FastAPI)¶
import hashlib
import hmac
import json
import time
from fastapi import FastAPI, Header, HTTPException, Request, status
app = FastAPI()
# Replace with your Webhook signing_secret or API Key's webhook_signing_secret
SIGNING_SECRET = "whs_your_secret_here"
@app.post("/webhooks/abelo")
async def receive_webhook(
request: Request,
x_abelo_signature: str = Header(..., alias="X-Abelo-Signature"),
x_abelo_timestamp: str = Header(..., alias="X-Abelo-Timestamp"),
x_abelo_topic: str = Header(..., alias="X-Abelo-Topic"),
x_abelo_delivery_id: str = Header(..., alias="X-Abelo-Delivery-Id")
):
# 1. Reject requests with no auth headers included
if not x_abelo_timestamp or not x_abelo_signature:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authentication headers"
)
# 2. Replay attack check (5 minute tolerance window)
current_time = int(time.time())
if abs(current_time - int(x_abelo_timestamp)) > 300:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Timestamp out of tolerance window"
)
# 3. Read raw request bytes (DO NOT parse JSON before verifying)
body_bytes = await request.body()
canonical_string = f"{x_abelo_timestamp}.{body_bytes.decode('utf-8')}"
# 4. Compute expected signature
digest = hmac.new(
key=SIGNING_SECRET.encode("utf-8"),
msg=canonical_string.encode("utf-8"),
digestmod=hashlib.sha256
).hexdigest()
expected_signature = f"sha256={digest}"
# 5. Constant-time comparison
if not hmac.compare_digest(expected_signature, x_abelo_signature):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid HMAC signature"
)
# 5. Process validated event
payload = await request.json()
print(f"✅ [VALID] Verified Webhook event received for topic '{x_abelo_topic}':{json.dumps(payload, indent=2)}")
return {"status": "ok", "delivery_id": x_abelo_delivery_id}
Node.js (Express)¶
const express = require("express");
const crypto = require("crypto");
const app = express();
const SIGNING_SECRET = "whs_your_secret_here";
// Preserves the raw buffer required for HMAC verification
app.post(
"/webhooks/abelo",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-abelo-signature"];
const timestamp = req.headers["x-abelo-timestamp"];
const topic = req.headers["x-abelo-topic"];
const deliveryId = req.headers["x-abelo-delivery-id"];
if (!signature || !timestamp) {
return res.status(401).json({ error: "Missing signature headers" });
}
// 1. Replay attack tolerance check (5 minutes)
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - parseInt(timestamp, 10)) > 300) {
return res.status(401).json({ error: "Timestamp out of range" });
}
// 2. Compute expected HMAC
const rawBodyString = req.body.toString("utf-8");
const canonicalString = `${timestamp}.${rawBodyString}`;
const expectedDigest = crypto
.createHmac("sha256", SIGNING_SECRET)
.update(canonicalString)
.digest("hex");
const expectedSignature = `sha256=${expectedDigest}`;
// 3. Constant-time comparison
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expectedSignature);
if (
signatureBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)
) {
return res.status(401).json({ error: "Signature mismatch" });
}
// 4. Process event
const event = JSON.parse(rawBodyString);
console.log(`✅ Verified event [${topic}]:`, event);
return res.status(200).json({ status: "ok", delivery_id: deliveryId });
}
);
app.listen(8000, () => console.log("Webhook receiver running on port 8000"));
5. Best Practices¶
- Always Use Raw Bytes: Compute the HMAC on the exact raw byte stream from the HTTP request. Parsing JSON and re-serializing can reorder keys or change whitespace, breaking the signature.
- Immediate Acknowledgment: Return a
200 OKwithin 15 seconds. If your business logic involves database writes or external API calls, offload them to an asynchronous worker queue. - Idempotency: Use the
X-Abelo-Delivery-Idheader to deduplicate retried deliveries.