Documentation Index
Fetch the complete documentation index at: https://help.withallo.com/llms.txt
Use this file to discover all available pages before exploring further.
Every webhook request includes a cryptographic signature so you can verify it came from Allo. Without verification, an attacker could send fake events to your endpoint.
Each webhook POST includes three headers:
| Header | Description | Example |
|---|
webhook-id | Unique message identifier | msg_2NfDKEm9sF8xK3pQr1Zt |
webhook-timestamp | Unix timestamp (seconds) when the message was sent | 1710510600 |
webhook-signature | One or more HMAC-SHA256 signatures | v1,K5oZfzN95Z3M+nrYOgGHfLGC7t8VjLtV... |
Signing secret
When you create a webhook endpoint, the response includes a signing_secret field. This secret has the format whsec_<base64key> and is used to verify signatures.
Store it securely — treat it like a password. If compromised, rotate it via the API.
Verification algorithm
Extract the headers
Get webhook-id, webhook-timestamp, and webhook-signature from the request headers.
Validate the timestamp
Reject requests where the timestamp is more than 5 minutes from your server’s current time. This prevents replay attacks.
Build the signed content
Concatenate the webhook ID, timestamp, and raw request body with periods:{webhook-id}.{webhook-timestamp}.{raw_body}
Compute the expected signature
- Strip the
whsec_ prefix from your signing secret.
- Base64-decode the remaining string to get the key bytes.
- Compute HMAC-SHA256 of the signed content using the key bytes.
- Base64-encode the result.
Compare signatures
The webhook-signature header may contain multiple signatures separated by spaces (for secret rotation). Each has a v1, prefix. Compare your computed signature against each one. If any match, the signature is valid.
Always use the raw request body for verification. If your framework parses JSON and re-serializes it, the signature will not match.
Code examples
const crypto = require("crypto");
function verifyWebhook(payload, headers, secret) {
const webhookId = headers["webhook-id"];
const webhookTimestamp = headers["webhook-timestamp"];
const webhookSignature = headers["webhook-signature"];
// Validate timestamp (5-minute tolerance)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(webhookTimestamp)) > 300) {
throw new Error("Timestamp too old");
}
// Build signed content
const signedContent = `${webhookId}.${webhookTimestamp}.${payload}`;
// Decode secret (strip "whsec_" prefix, base64-decode)
const secretBytes = Buffer.from(secret.split("_")[1], "base64");
// Compute HMAC-SHA256
const computed = crypto
.createHmac("sha256", secretBytes)
.update(signedContent)
.digest("base64");
// Compare against each signature in the header
const signatures = webhookSignature
.split(" ")
.map((sig) => sig.split(",")[1]);
if (!signatures.some((sig) => sig === computed)) {
throw new Error("Invalid signature");
}
return JSON.parse(payload);
}
// Express.js example
const express = require("express");
const app = express();
app.post(
"/webhooks/allo",
express.raw({ type: "application/json" }),
(req, res) => {
try {
const event = verifyWebhook(
req.body.toString(),
req.headers,
process.env.WEBHOOK_SECRET
);
console.log("Verified event:", event.topic);
res.sendStatus(200);
} catch (err) {
console.error("Verification failed:", err.message);
res.sendStatus(400);
}
}
);
import hmac
import hashlib
import base64
import time
import json
def verify_webhook(payload: bytes, headers: dict, secret: str) -> dict:
webhook_id = headers.get("webhook-id")
webhook_timestamp = headers.get("webhook-timestamp")
webhook_signature = headers.get("webhook-signature")
# Validate timestamp (5-minute tolerance)
now = int(time.time())
if abs(now - int(webhook_timestamp)) > 300:
raise ValueError("Timestamp too old")
# Build signed content
signed_content = f"{webhook_id}.{webhook_timestamp}.{payload.decode()}"
# Decode secret (strip "whsec_" prefix, base64-decode)
secret_bytes = base64.b64decode(secret.split("_")[1])
# Compute HMAC-SHA256
computed = base64.b64encode(
hmac.new(
secret_bytes,
signed_content.encode(),
hashlib.sha256,
).digest()
).decode()
# Compare against each signature in the header
signatures = [
sig.split(",")[1]
for sig in webhook_signature.split(" ")
]
if computed not in signatures:
raise ValueError("Invalid signature")
return json.loads(payload)
# Flask example
from flask import Flask, request
app = Flask(__name__)
@app.route("/webhooks/allo", methods=["POST"])
def handle_webhook():
try:
event = verify_webhook(
request.get_data(),
request.headers,
os.environ["WEBHOOK_SECRET"],
)
print(f"Verified event: {event['topic']}")
return "", 200
except ValueError as e:
print(f"Verification failed: {e}")
return "", 400
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"time"
)
func verifyWebhook(payload []byte, headers http.Header, secret string) error {
webhookID := headers.Get("webhook-id")
webhookTimestamp := headers.Get("webhook-timestamp")
webhookSignature := headers.Get("webhook-signature")
// Validate timestamp (5-minute tolerance)
ts, _ := strconv.ParseInt(webhookTimestamp, 10, 64)
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return fmt.Errorf("timestamp too old")
}
// Build signed content
signedContent := fmt.Sprintf("%s.%s.%s", webhookID, webhookTimestamp, string(payload))
// Decode secret (strip "whsec_" prefix, base64-decode)
parts := strings.SplitN(secret, "_", 2)
secretBytes, _ := base64.StdEncoding.DecodeString(parts[1])
// Compute HMAC-SHA256
mac := hmac.New(sha256.New, secretBytes)
mac.Write([]byte(signedContent))
computed := base64.StdEncoding.EncodeToString(mac.Sum(nil))
// Compare against each signature in the header
for _, sig := range strings.Split(webhookSignature, " ") {
parts := strings.SplitN(sig, ",", 2)
if len(parts) == 2 && parts[1] == computed {
return nil
}
}
return fmt.Errorf("invalid signature")
}
Always verify signatures in production. Skipping verification exposes your application to spoofed webhook events.