Skip to main content

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-Signature header
  • ❌ If no secret is configured, the X-Agc-Signature header 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-Signature is the computed HMAC
  • X-Agc-Timestamp is 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-Signature
  • X-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.


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.

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");
});

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