Validating Deliveries
To ensure webhook deliveries are authentic and untampered, webhook requests can be signed using HMAC.
Signature verification is optional and only applies when a secret is configured on the webhook.
When Is a Signature Included?
- ✅ If a webhook has a secret configured, requests will include the
X-Agc-Signatureheader - ❌ If no secret is configured, the
X-Agc-Signatureheader will NOT be included
Signature Headers
When enabled, the following headers are used for verification:
X-Agc-Signature: <hex-encoded hmac>
X-Agc-Timestamp: <ISO-8601 timestamp>
X-Agc-Signatureis the computed HMACX-Agc-Timestampis included in the signed message to prevent replay attacks
Signature Construction
The signature is generated using:
- Algorithm:
HMAC-SHA256 - Secret: The webhook secret you configured in the portal
- Signed value: A concatenation of the timestamp and the raw request body
Signed Payload Format
<timestamp>.<raw_request_body>
Example:
2026-01-22T06:40:00.000Z.{"version":"1.0","tenant_id":"tenant_abc",...}
⚠️ The raw request body must be used exactly as received Do not parse, reformat, pretty-print, or reorder JSON keys before verification.
Verification Steps
1. Read Required Values
From the incoming request:
X-Agc-SignatureX-Agc-Timestamp- Raw request body (string)
- Your webhook secret
2. Recreate the Signed Payload
signed_payload = X-Agc-Timestamp + "." + raw_body
3. Compute the Expected Signature
Using HMAC-SHA256:
expected_signature = HMAC_SHA256(secret, signed_payload)
The resulting signature is hex-encoded lowercase.
4. Compare Signatures Securely
Compare the received signature with the computed signature using a constant-time comparison.
If they match, the webhook is valid.
Timestamp Validation (Recommended)
To protect against replay attacks, validate the timestamp:
- Parse
X-Agc-Timestamp - Reject requests older than a defined window (e.g. 5 minutes)
Example rule:
abs(now - timestamp) <= 300 seconds
End-to-End Verification Examples
This section demonstrates a complete webhook handling flow using a shared webhook secret.
⚠️ Always use the raw request body exactly as received.
- Javascript
- Typescript
- Python
import crypto from "crypto";
import express from "express";
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const MAX_SKEW_SECONDS = 5 * 60;
// --------------------------------------------------
// Raw body capture (MUST run before signature check)
// --------------------------------------------------
app.use(
express.json({
verify: (req, _res, buf) => {
req.rawBody = buf.toString("utf8");
},
})
);
// --------------------------------------------------
// Helpers
// --------------------------------------------------
function getHeader(req, name) {
return req.get(name) || undefined;
}
function computeHmacHex(secret, timestamp, rawBody) {
const signedPayload = `${timestamp}.${rawBody}`;
return crypto.createHmac("sha256", secret).update(signedPayload, "utf8").digest("hex");
}
function safeEqualHex(aHex, bHex) {
// timingSafeEqual requires buffers of equal length
if (aHex.length !== bHex.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(aHex, "hex"), Buffer.from(bHex, "hex"));
} catch {
// Invalid hex input
return false;
}
}
function isTimestampFresh(isoTimestamp, maxSkewSeconds) {
const ms = Date.parse(isoTimestamp);
if (Number.isNaN(ms)) return false;
const ageSeconds = Math.abs(Date.now() - ms) / 1000;
return ageSeconds <= maxSkewSeconds;
}
function verifyAgcSignature({ secret, signature, timestamp, rawBody }) {
const expected = computeHmacHex(secret, timestamp, rawBody);
return safeEqualHex(signature, expected);
}
// --------------------------------------------------
// Webhook endpoint
// --------------------------------------------------
app.post("/webhooks/agc", (req, res) => {
const signature = getHeader(req, "X-Agc-Signature");
const deliveryTimestamp = getHeader(req, "X-Agc-Timestamp");
const eventType = getHeader(req, "X-Agc-Event-Type");
const eventId = getHeader(req, "X-Agc-Event-Id");
// Signature & timestamp required if a secret is configured
if (WEBHOOK_SECRET) {
if (!signature) return res.status(401).send("Missing signature");
if (!deliveryTimestamp) return res.status(401).send("Missing timestamp");
if (!req.rawBody) return res.status(400).send("Missing raw body");
if (!isTimestampFresh(deliveryTimestamp, MAX_SKEW_SECONDS)) {
return res.status(401).send("Expired timestamp");
}
const valid = verifyAgcSignature({
secret: WEBHOOK_SECRET,
signature,
timestamp: deliveryTimestamp,
rawBody: req.rawBody,
});
if (!valid) return res.status(401).send("Invalid signature");
}
// --------------------------------------------------
// Process event (MUST be idempotent)
// --------------------------------------------------
const payload = req.body;
console.log("Webhook received:", {
eventId,
eventType,
payload,
});
return res.sendStatus(200);
});
// --------------------------------------------------
// Server
// --------------------------------------------------
app.listen(3000, () => {
console.log("Webhook listener running on port 3000");
});
import crypto from "crypto";
import express, { Request, Response } from "express";
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const MAX_SKEW_SECONDS = 5 * 60;
// ---- Raw body capture (must be BEFORE signature verification) ----
type RawBodyRequest = Request & { rawBody?: string };
app.use(
express.json({
verify: (req: RawBodyRequest, _res, buf) => {
req.rawBody = buf.toString("utf8");
},
})
);
// ---- Helpers ----
const getHeader = (req: Request, name: string): string | undefined => req.get(name) ?? undefined;
function computeHmacHex(secret: string, timestamp: string, rawBody: string): string {
const signedPayload = `${timestamp}.${rawBody}`;
return crypto.createHmac("sha256", secret).update(signedPayload, "utf8").digest("hex");
}
function safeEqualHex(aHex: string, bHex: string): boolean {
// timingSafeEqual requires equal-length buffers; short-circuit safely
if (aHex.length !== bHex.length) return false;
try {
return crypto.timingSafeEqual(Buffer.from(aHex, "hex"), Buffer.from(bHex, "hex"));
} catch {
// invalid hex input
return false;
}
}
function isTimestampFresh(isoTimestamp: string, maxSkewSeconds: number): boolean {
const ms = Date.parse(isoTimestamp);
if (Number.isNaN(ms)) return false;
const ageSeconds = Math.abs(Date.now() - ms) / 1000;
return ageSeconds <= maxSkewSeconds;
}
function verifyAgcSignature(opts: { secret: string; signature: string; timestamp: string; rawBody: string }): boolean {
const expected = computeHmacHex(opts.secret, opts.timestamp, opts.rawBody);
return safeEqualHex(opts.signature, expected);
}
// ---- Route ----
app.post("/webhooks/agc", (req: RawBodyRequest, res: Response) => {
const signature = getHeader(req, "X-Agc-Signature");
const deliveryTimestamp = getHeader(req, "X-Agc-Timestamp");
const eventType = getHeader(req, "X-Agc-Event-Type");
const eventId = getHeader(req, "X-Agc-Event-Id");
// If you configured a secret, signature + timestamp are required.
if (WEBHOOK_SECRET) {
if (!signature) return res.status(401).send("Missing signature");
if (!deliveryTimestamp) return res.status(401).send("Missing timestamp");
if (!req.rawBody) return res.status(400).send("Missing raw body");
if (!isTimestampFresh(deliveryTimestamp, MAX_SKEW_SECONDS)) {
return res.status(401).send("Expired timestamp");
}
const ok = verifyAgcSignature({
secret: WEBHOOK_SECRET,
signature,
timestamp: deliveryTimestamp,
rawBody: req.rawBody,
});
if (!ok) return res.status(401).send("Invalid signature");
}
// Process event (make idempotent using event_id from body or X-Agc-Event-Id)
const payload = req.body;
console.log("Webhook received:", {
eventId,
eventType,
payload,
});
return res.sendStatus(200);
});
// ---- Server ----
app.listen(3000, () => {
console.log("Webhook listener running on port 3000");
});
import os
import hmac
import hashlib
from fastapi import FastAPI, Request, HTTPException
from datetime import datetime, timezone
app = FastAPI()
WEBHOOK_SECRET = os.getenv("AGC_WEBHOOK_SECRET")
def verify_signature(secret, signature, timestamp, raw_body):
payload = f"{timestamp}.{raw_body}".encode("utf-8")
expected = hmac.new(
secret.encode("utf-8"),
payload,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
@app.post("/webhooks/agc")
async def handle_webhook(request: Request):
raw_body = (await request.body()).decode("utf-8")
signature = request.headers.get("X-Agc-Signature")
timestamp = request.headers.get("X-Agc-Timestamp")
event_type = request.headers.get("X-Agc-Event-Type")
event_id = request.headers.get("X-Agc-Event-Id")
# 1. Signature required if secret exists
if WEBHOOK_SECRET and not signature:
raise HTTPException(status_code=401, detail="Missing signature")
# 2. Timestamp validation (5 minutes)
if timestamp:
age = abs(
(request.state.now if hasattr(request.state, "now") else 0)
)
# Simpler timestamp check
event_time = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
now = datetime.now(timezone.utc)
if abs((now - event_time).total_seconds()) > 300:
raise HTTPException(status_code=401, detail="Expired timestamp")
# 3. Verify signature
if WEBHOOK_SECRET and signature and timestamp:
if not verify_signature(
WEBHOOK_SECRET, signature, timestamp, raw_body
):
raise HTTPException(status_code=401, detail="Invalid signature")
# 4. Process event
payload = await request.json()
print("Webhook received:", {
"event_id": event_id,
"event_type": event_type,
"payload": payload,
})
return {"status": "ok"}
Handling Missing Signatures
If the X-Agc-Signature header is missing:
- ✅ Check if the webhook is configured without a secret
- ❌ If a secret is configured, treat the request as invalid
Best Practices
- Always use HTTPS
- Store webhook secrets securely
- Use raw request bodies for verification
- Enforce a timestamp tolerance window
- Log failed verification attempts
- Rotate secrets periodically