Verify webhook signatures
Check the X-IconEngage-Signature header on every event webhook delivery, with working Node.js and Python examples.
Every event webhook delivery is signed with your webhook's signing secret. Check the signature before you trust a delivery. It proves the request came from IconEngage, that the body was not changed, and that it is not an old request being replayed.
The signature header
Each delivery carries:
X-IconEngage-Signature: t=1760175072,k=k1,v1=5f2b…9c3e| Part | Meaning |
|---|---|
t | When the delivery was signed, in Unix seconds |
k | Which signing key was used (an identifier, not a secret) |
v1 | The signature: a hex HMAC-SHA256 |
The signed content is the timestamp, a full stop, and the raw request body, exactly as received:
<t>.<raw body>The key is the signing secret shown once when you created the webhook (see Event webhooks).
How to verify
- Read the raw request body as bytes. Do this before parsing the JSON: parsing and re-serialising changes the bytes and the check fails.
- Split
X-IconEngage-Signatureon commas and readtandv1. Ignore any part you don't recognise, so a future signature version does not break you. - Compute HMAC-SHA256 of
t + "." + raw bodywith your signing secret, as lowercase hex. - Compare it with
v1using a constant-time comparison. - Refuse the delivery if
tis more than 300 seconds (5 minutes) from your server's clock, in either direction. - Use the
tfrom inside the signature. Don't use theX-IconEngage-Timestampheader: it is not signed.
If any check fails, answer with a non-2xx status and don't act on the event.
After a delivery passes, dedupe on the body's id (also in X-IconEngage-Event-Id). The same event can arrive more than once.
Node.js example
Using Express, keeping the raw body:
const crypto = require('crypto')
const express = require('express')
const app = express()
const SECRET = process.env.ICONENGAGE_WEBHOOK_SECRET
const TOLERANCE_SECONDS = 300
function verify(rawBody, header) {
if (!header) return false
const parts = Object.fromEntries(
header.split(',').map((p) => p.trim().split('=', 2)),
)
const t = Number(parts.t)
if (!Number.isInteger(t) || !parts.v1) return false
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${parts.t}.`)
.update(rawBody)
.digest()
const given = Buffer.from(parts.v1, 'hex')
return given.length === expected.length && crypto.timingSafeEqual(given, expected)
}
app.post('/iconengage/events', express.raw({ type: 'application/json' }), (req, res) => {
if (!verify(req.body, req.get('X-IconEngage-Signature'))) {
return res.status(401).end()
}
const event = JSON.parse(req.body.toString('utf8'))
// Dedupe on event.id, then handle event.type
res.status(200).end()
})
app.listen(3000)Python example
Using Flask:
import hashlib
import hmac
import os
import time
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["ICONENGAGE_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, header: str | None) -> bool:
if not header:
return False
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t"), parts.get("v1")
if not t or not t.isdigit() or not v1:
return False
if abs(time.time() - int(t)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
@app.post("/iconengage/events")
def events():
if not verify(request.get_data(), request.headers.get("X-IconEngage-Signature")):
abort(401)
event = request.get_json()
# Dedupe on event["id"], then handle event["type"]
return "", 200Test your check
Send a test event from the webhook's Delivery log (see Event webhooks). It is signed exactly like a real event. If your endpoint refuses it, the log shows your response code.
If verification keeps failing
- You parsed the body first. Verify the raw bytes, then parse.
- Wrong secret. Each webhook has its own secret. If you lost it, there is no way to see it again. Delete the webhook and create a new one to get a new secret.
- Clock drift. If your server's clock is more than 5 minutes off, every delivery fails the time check. Sync it with NTP.
- Whitespace or encoding changes by a proxy or framework in front of your code. Make sure the body reaches your check unchanged.
Still need help?
Write to us at [email protected] and tell us what you were trying to do. Already a customer? Sign in to IconEngage — the same articles are under Help in the app.