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
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | SentientOne-Workflows/1 |
X-SentientOne-Timestamp | Unix time in seconds when it was sent |
X-SentientOne-Signature | sha256= followed by the hex HMAC-SHA256 of <timestamp>.<raw body>, keyed with your whsec_ secret |
X-Request-Id | The 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
Read the two headers
Take
X-SentientOne-TimestampandX-SentientOne-Signaturefrom the request. - 2
Check the timestamp
Refuse deliveries older than a window you choose (5 minutes in the examples) to block replays.
- 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
Compare in constant time
Use
crypto.timingSafeEqualorhmac.compare_digest, never==.
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)})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 "", 200You'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.