Skip to content

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

  1. Abelo constructs a canonical string combining the timestamp and the raw UTF-8 JSON request body:
    {timestamp}.{raw_body}
    
  2. Computes the HMAC-SHA256 hash using your secret (signing_secret for org webhooks or webhook_signing_secret for per-message webhooks).
  3. Prepends sha256= to the hex digest and sends it in the X-Abelo-Signature header.

3. Verification Algorithm

To verify an incoming webhook:

  1. Extract Headers: Read X-Abelo-Timestamp and X-Abelo-Signature.
  2. Replay Protection: Verify that the timestamp is within 300 seconds (5 minutes) of your server time.
  3. Construct Canonical Message: Compute f"{timestamp}.{raw_request_body}" using the raw, unparsed request bytes.
  4. Compute Expected Signature: Calculate sha256= + HMAC_SHA256(secret, canonical_message).
  5. Constant-Time Comparison: Use hmac.compare_digest or crypto.timingSafeEqual to 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

  1. 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.
  2. Immediate Acknowledgment: Return a 200 OK within 15 seconds. If your business logic involves database writes or external API calls, offload them to an asynchronous worker queue.
  3. Idempotency: Use the X-Abelo-Delivery-Id header to deduplicate retried deliveries.