Verifying Deepwick webhook signatures — Node, Python, and Go
How to verify the HMAC-SHA256 signature on Deepwick webhook alerts in Node.js, Python and Go, and why it matters before you let alerts place trades.
If you set a webhook URL when you created a Deepwick alert, we POST to it every time the alert fires. We sign every payload with an HMAC-SHA256 keyed on the per-alert secret we returned once at creation time. The signature goes in the X-Deepwick-Signature header in the format sha256=<hex>.
Verifying it on your side is five lines of code. Skipping it is a common way to end up with someone else's script calling your endpoint and triggering real trades.
What we send
Every alert fire delivers a POST to your URL with these headers:
Content-Type: application/jsonUser-Agent: deepwick-alerts/1.0X-Deepwick-Event: alert.firedX-Deepwick-Event-Id: <UUID>— the same on every delivery of an event, useful for idempotencyX-Deepwick-Alert-Id: <UUID>X-Deepwick-Fired-At: <ISO-8601>X-Deepwick-Signature: sha256=<hex>— HMAC-SHA256 over the raw request bodyX-Deepwick-Retry: 1— only present on manual retries from the dashboard
The body is a JSON object:
{
"event": "alert.fired",
"id": "3f1c…",
"alert_id": "9c8b…",
"symbol": "BTCUSDT",
"exchange": "binance",
"condition": "price_above",
"threshold": 70000,
"cross_threshold": null,
"observed_value": 70123.4,
"name": "BTC breakout",
"fired_at": "2026-10-01T07:42:00.000Z"
}
The two failure modes that catch teams out
1. Comparing signatures in a non-constant-time way. The classic === check on signature strings leaks information through timing differences. Use a constant-time comparator (every language has one — Node's crypto.timingSafeEqual, Python's hmac.compare_digest, Go's hmac.Equal).
2. Re-serializing the JSON before verifying. If you JSON.parse(req.body) and then JSON.stringify(...) before HMACing, the key order changes, whitespace changes, and the signature breaks. Always HMAC the raw bytes that came off the wire. Deepwick signs the exact body we send.
Node.js (Express, Fastify, plain http, etc.)
import crypto from 'node:crypto';
function verifyDeepwick(req, secret) {
const header = req.headers['x-deepwick-signature'];
if (!header?.startsWith('sha256=')) return false;
const provided = header.slice('sha256='.length);
// req.rawBody is the *exact* bytes that arrived on the wire. If you're
// using express.json() you'll need to also mount `verify: (req, res, buf)
// => { req.rawBody = buf }` to capture it before parsing.
const expected = crypto
.createHmac('sha256', secret)
.update(req.rawBody)
.digest('hex');
const a = Buffer.from(provided, 'hex');
const b = Buffer.from(expected, 'hex');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/deepwick', express.json({
verify: (req, _res, buf) => { req.rawBody = buf; }
}), (req, res) => {
const alert = JSON.parse(req.rawBody.toString('utf8'));
if (!verifyDeepwick(req, process.env.DEEPWICK_SECRET)) {
return res.status(401).send('bad signature');
}
// … handle the alert
res.status(204).end();
});
Python (Flask, FastAPI, Django, etc.)
import hmac, hashlib
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
def verify_deepwick(raw_body: bytes, header: str | None, secret: str) -> bool:
if not header or not header.startswith("sha256="):
return False
provided = header.removeprefix("sha256=")
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(provided, expected)
@app.post("/deepwick")
async def hook(request: Request):
raw = await request.body()
sig = request.headers.get("x-deepwick-signature")
if not verify_deepwick(raw, sig, SECRET):
raise HTTPException(status_code=401, detail="bad signature")
alert = await request.json()
# … handle the alert
return {"ok": True}
Go (net/http, chi, gin, echo)
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"io"
"net/http"
"strings"
)
func verifyDeepwick(r *http.Request, secret []byte) bool {
h := r.Header.Get("X-Deepwick-Signature")
if !strings.HasPrefix(h, "sha256=") {
return false
}
provided, err := hex.DecodeString(strings.TrimPrefix(h, "sha256="))
if err != nil {
return false
}
body, err := io.ReadAll(r.Body)
if err != nil {
return false
}
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expected := mac.Sum(nil)
return subtle.ConstantTimeCompare(provided, expected) == 1
}
func handler(w http.ResponseWriter, r *http.Request) {
if !verifyDeepwick(r, []byte(secret)) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
// … handle the alert
w.WriteHeader(http.StatusNoContent)
}
Rotating the secret
If a secret leaks (an ex-employee, a leaked log, a commit history), rotate it without losing the alert:
curl -X POST https://deepwick.com/api/platform/alerts/<id>/rotate-secret \
-H "Cookie: dw_session=…" \
| jq -r .webhookSecret
The endpoint returns the new plaintext secret once. Update your verifier with it. There is no overlap window — Deepwick signs with the new secret immediately. Plan the cutover so your verifier accepts the old OR new secret during the deploy.
Idempotency
X-Deepwick-Event-Id is stable across retries. Store it. If you see the same ID twice (network glitch + a manual retry from the dashboard), treat the second delivery as a no-op. We don't currently have at-least-once delivery guarantees, but the event ID is your hook for building them.
The cron retries for you
The cron does a single retry on 5xx or network errors. After that the event row is marked failed in /dashboard/alerts/<id>/events and you can re-deliver from the dashboard with one click. The retry re-signs the same body and re-POSTs to the same URL. Your verifier sees the same X-Deepwick-Event-Id — that's your signal to dedupe.
That's it. Five lines per stack, two failure modes to avoid, and you have a webhook that's safe to drive real trades from.