Skip to content

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
PartMeaning
tWhen the delivery was signed, in Unix seconds
kWhich signing key was used (an identifier, not a secret)
v1The 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

  1. Read the raw request body as bytes. Do this before parsing the JSON: parsing and re-serialising changes the bytes and the check fails.
  2. Split X-IconEngage-Signature on commas and read t and v1. Ignore any part you don't recognise, so a future signature version does not break you.
  3. Compute HMAC-SHA256 of t + "." + raw body with your signing secret, as lowercase hex.
  4. Compare it with v1 using a constant-time comparison.
  5. Refuse the delivery if t is more than 300 seconds (5 minutes) from your server's clock, in either direction.
  6. Use the t from inside the signature. Don't use the X-IconEngage-Timestamp header: 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:

js
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:

python
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 "", 200

Test 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.