Skip to content

Workflows

Verify webhook signatures

Check the X-SentientOne-Signature HMAC-SHA256 header on Webhook block deliveries, with Node and Python examples.

Guide2 min readUpdated 23 October 2026

The Webhook block POSTs JSON to your endpoint. Each delivery is signed with the workflow's signing secret so your endpoint can prove it came from SentientOne and wasn't changed on the way.

What's sent

HeaderValue
Content-Typeapplication/json
User-AgentSentientOne-Workflows/1
X-SentientOne-TimestampUnix time in seconds when it was sent
X-SentientOne-Signaturesha256= followed by the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with your whsec_ secret
X-Request-IdThe run's correlation id, so you can match the delivery to the run

Before you start

  • The signing secret, from Signing secret in the workflow designer. It starts with whsec_, is shown once and is stored encrypted.
  • An endpoint that can read the raw request body. The signature covers the exact bytes; re-serialising parsed JSON breaks it.

Steps

  1. 1

    Read the two headers

    Take X-SentientOne-Timestamp and X-SentientOne-Signature from the request.

  2. 2

    Check the timestamp

    Refuse deliveries older than a window you choose (5 minutes in the examples) to block replays.

  3. 3

    Compute the HMAC

    HMAC-SHA256 over the timestamp, a full stop and the raw body, keyed with your secret, as lowercase hex, prefixed with sha256=.

  4. 4

    Compare in constant time

    Use crypto.timingSafeEqual or hmac.compare_digest, never ==.

Node.js (Express)
import crypto from "node:crypto"import express from "express"const app = express()const SECRET = process.env.SENTIENTONE_WEBHOOK_SECRET // whsec_…// Keep the raw body: the signature is over the exact bytes sentapp.post("/sentientone", express.raw({ type: "application/json" }), (req, res) => {  const timestamp = req.get("X-SentientOne-Timestamp") || ""  const signature = req.get("X-SentientOne-Signature") || ""  // Refuse old deliveries (5 minutes here; pick your own window)  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400)  const expected =    "sha256=" +    crypto.createHmac("sha256", SECRET).update(timestamp + "." + req.body.toString("utf8")).digest("hex")  const ok =    signature.length === expected.length &&    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))  if (!ok) return res.sendStatus(401)  const payload = JSON.parse(req.body.toString("utf8"))  // … handle payload  res.sendStatus(200)})
Python (Flask)
import hashlib, hmac, os, timefrom flask import Flask, abort, requestapp = Flask(__name__)SECRET = os.environ["SENTIENTONE_WEBHOOK_SECRET"].encode()  # whsec_…@app.post("/sentientone")def sentientone():    timestamp = request.headers.get("X-SentientOne-Timestamp", "")    signature = request.headers.get("X-SentientOne-Signature", "")    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:        abort(400)    raw = request.get_data()  # the exact bytes sent    digest = hmac.new(SECRET, timestamp.encode() + b"." + raw, hashlib.sha256).hexdigest()    if not hmac.compare_digest(signature, "sha256=" + digest):        abort(401)    payload = request.get_json()    # … handle payload    return "", 200

You're done when

A test run's Webhook block shows succeeded, your endpoint accepted the delivery, and a request with a changed body or an old timestamp is refused.

Rotating the secret

Rotate the signing secret in the designer if it leaks. The new secret applies from the next webhook sent, so deploy it to your endpoint at the same time.

If something goes wrong

Every signature fails

Most often the body was parsed and re-serialised before hashing. Hash the raw bytes. Also check the secret has no stray whitespace and that you included the sha256= prefix.

The Webhook block fails before reaching me

The URL must be on a public address; private, loopback and link-local addresses are refused and redirects aren't followed. The block's output in the run shows the error.

Still stuck?

Email us with what you tried and any request id. A person replies.

support@sentientone.ai