Verifying the signature
Every request is signed with HMAC-SHA256 using your endpoint's secret. Verifying that signature is mandatory: without it, anyone who learns the receiver URL can send you a made-up event.
The secret
- Format —
whsec_plus 43 base64url characters. It is generated on the Smengo server; you cannot supply your own. - It is shown exactly once — when the endpoint is created and after a rotation. There is no way to see it again, in the UI or through the API: it is stored behind restricted access and never reaches logs.
- Lost the secret? Click Rotate secret and store the new one.
Header format
Smengo-Signature: t=1785921323,v1=5a0f3c9e…c81b
t— unix seconds of the signing moment (the same value asSmengo-Timestamp).v1— lowercase hex HMAC-SHA256 of`${t}.${rawBody}`, whererawBodyis the raw request body before parsing.- During a rotation window there are two signatures, separated by a space:
Smengo-Signature: t=1785921323,v1=5a0f3c9e…c81b v1=9d2b71af…40ea
Ignore unknown keys (a future v2=, for example) — they will show up long before v1 goes away.
Verification algorithm
- Take the raw body bytes — before
JSON.parseand before any transformation. A re-serialized JSON is a different string and yields a different signature. - Parse the header: one
tvalue and allv1values. - Reject the request if
|now − t| > 300seconds (a 5-minute window that blocks replaying an old request). Parsetstrictly as ASCII digits: Python'sisdigit()accepts Unicode digits such as١٢٣, andint()then throws on them. - Compute
HMAC-SHA256(secret, "${t}.${rawBody}")in hex. - Compare it to every
v1in constant time and over bytes, not strings:timingSafeEqualthrows on buffers of different length (check the length first), andcompare_digestthrowsTypeErroron a string containing non-ASCII characters (encode the candidate to bytes). - If at least one matches, the event is genuine. If none does, reply
401/403and process nothing.
Iterate over all
v1values. A client that only takes the first signature stops accepting events for a whole day on the first secret rotation.
Node.js
const crypto = require('node:crypto')
const TOLERANCE_SEC = 300
function verifySmengoSignature(rawBody, header, secret) {
if (typeof header !== 'string' || header.length === 0) return false
// 1. Parse the header: one t, ALL v1 values (two during rotation).
let timestamp = null
const signatures = []
for (const token of header.split(/[\s,]+/)) {
const eq = token.indexOf('=')
if (eq <= 0) continue
const key = token.slice(0, eq)
const value = token.slice(eq + 1)
if (key === 't') {
if (timestamp === null && /^\d+$/.test(value)) timestamp = Number(value)
} else if (key === 'v1' && value.length > 0) {
signatures.push(value)
}
}
if (timestamp === null || signatures.length === 0) return false
// 2. The 5-minute window — BEFORE comparing hashes.
const nowSec = Math.floor(Date.now() / 1000)
if (Math.abs(nowSec - timestamp) > TOLERANCE_SEC) return false
// 3. The signature is computed over the RAW body.
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`, 'utf8').digest('hex'),
'utf8',
)
// 4. Constant-time comparison with a length pre-check.
return signatures.some((candidate) => {
const actual = Buffer.from(candidate, 'utf8')
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected)
})
}
Receiving it in Express — the body must stay raw:
const express = require('express')
const app = express()
// express.raw, not express.json: we need the original Buffer.
app.post('/hooks/smengo', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8')
if (!verifySmengoSignature(rawBody, req.get('Smengo-Signature'), process.env.SMENGO_WEBHOOK_SECRET)) {
return res.status(401).end()
}
const event = JSON.parse(rawBody)
// Deduplicate by event.id (= the Smengo-Event-Id header) and process ASYNCHRONOUSLY:
// the reply has to fit in 15 seconds.
enqueue(event)
res.status(200).end()
})
Python
import hashlib
import hmac
import time
TOLERANCE_SEC = 300
def verify_smengo_signature(raw_body: bytes, header: str, secret: str) -> bool:
if not header:
return False
# 1. Parse the header: one t, ALL v1 values (two during rotation).
timestamp = None
signatures = []
for token in header.replace(",", " ").split():
key, _, value = token.partition("=")
if key == "t":
# NOT isdigit(): it accepts Unicode digits ("١٢٣", "²³") on which
# int() raises ValueError. The length bound guards against a header
# of thousands of digits (int() throws on that too).
if timestamp is None and value.isascii() and value.isdecimal() and len(value) <= 20:
timestamp = int(value)
elif key == "v1" and value:
signatures.append(value)
if timestamp is None or not signatures:
return False
# 2. The 5-minute window — BEFORE comparing hashes.
if abs(int(time.time()) - timestamp) > TOLERANCE_SEC:
return False
# 3. The signature is computed over the RAW body bytes.
expected = hmac.new(
secret.encode("utf-8"),
f"{timestamp}.".encode("utf-8") + raw_body,
hashlib.sha256,
).hexdigest().encode("ascii")
# 4. Constant-time comparison over BYTES. compare_digest raises TypeError on
# a STRING containing non-ASCII (a length pre-check does not help:
# len("é" * 64) == 64), and anyone can send that header. On bytes of
# different length it simply returns False, so no length check is needed.
return any(
hmac.compare_digest(candidate.encode("utf-8", "ignore"), expected)
for candidate in signatures
)
Receiving it in Flask — request.get_data() returns the raw bytes:
from flask import Flask, request
app = Flask(__name__)
@app.post("/hooks/smengo")
def smengo_webhook():
raw_body = request.get_data()
header = request.headers.get("Smengo-Signature", "")
if not verify_smengo_signature(raw_body, header, SMENGO_WEBHOOK_SECRET):
return "", 401
event = request.get_json()
enqueue(event) # deduplicate by event["id"], process asynchronously
return "", 200
Secret rotation
The Rotate secret button in the webhook card issues a new secret and opens a 24-hour window:
- Right after the rotation every delivery is signed with both secrets — the old and the new one (two
v1values separated by a space). - Within those 24 hours, update the secret on your side. Reception never breaks: code that iterates over all
v1values accepts events both before and after the switch. - When the window closes the old secret is dropped and the header carries a single signature again.
While the window is open, another rotation is unavailable. If a secret is compromised and the old one must die immediately, delete the endpoint and create a new one.
Common mistakes
| Symptom | Cause |
|---|---|
| The signature doesn't match although the body looks right | the body was re-serialized after parsing (JSON.stringify(JSON.parse(body))) — compute the HMAC over the raw bytes |
| Reception broke right after a secret rotation | the code takes the first v1 instead of iterating over all of them |
| An exception inside the comparison | timingSafeEqual was called on buffers of different length (check the length first), or compare_digest on a string containing non-ASCII (compare bytes) |
The receiver answers 500 instead of 401 to a garbage header |
t is parsed with isdigit(), or the signature is compared as strings — both throw on a header anyone can send without knowing the secret |
| Every request is rejected as "too old" | the receiver's clock drifted; the tolerance is 5 minutes, sync time over NTP |
| The signature matches but events arrive twice | that's the normal at-least-once mode — deduplicate by Smengo-Event-Id |
Next
- Retries and failures — what happens if you don't reply
2xx. - Events — the body you have just verified.