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 as Smengo-Timestamp).
  • v1 — lowercase hex HMAC-SHA256 of `${t}.${rawBody}`, where rawBody is 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

  1. Take the raw body bytes — before JSON.parse and before any transformation. A re-serialized JSON is a different string and yields a different signature.
  2. Parse the header: one t value and all v1 values.
  3. Reject the request if |now − t| > 300 seconds (a 5-minute window that blocks replaying an old request). Parse t strictly as ASCII digits: Python's isdigit() accepts Unicode digits such as ١٢٣, and int() then throws on them.
  4. Compute HMAC-SHA256(secret, "${t}.${rawBody}") in hex.
  5. Compare it to every v1 in constant time and over bytes, not strings: timingSafeEqual throws on buffers of different length (check the length first), and compare_digest throws TypeError on a string containing non-ASCII characters (encode the candidate to bytes).
  6. If at least one matches, the event is genuine. If none does, reply 401/403 and process nothing.

Iterate over all v1 values. 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:

  1. Right after the rotation every delivery is signed with both secrets — the old and the new one (two v1 values separated by a space).
  2. Within those 24 hours, update the secret on your side. Reception never breaks: code that iterates over all v1 values accepts events both before and after the switch.
  3. 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

Was this article helpful?