If your Solana bot takes trading actions on an incoming webhook, you need to know the request actually came from your data provider — not a scanner that noticed your endpoint and started firing forged payloads. HMAC-SHA256 signatures solve this, but most webhook docs stop at "here is the header" and leave the tricky parts (raw-body handling, timestamp windows, constant-time comparison) to the reader.
This guide walks through a complete verification implementation in both Node.js and Python, using the MadeOnSol copy-trade webhook as a concrete example. The same technique applies to any webhook that signs with HMAC: Stripe, GitHub, Shopify, and most Solana data APIs use the same general pattern.
The threat model
An unsigned webhook endpoint has two obvious problems:
- Forgery — anyone who discovers the URL can POST fake trade signals to it. A bot that auto-buys tokens on receipt is a drained wallet waiting to happen.
- Replay — even a real webhook payload can be captured (by a middlebox, a logged reverse proxy, an exposed Cloudflare tunnel) and replayed later, when the underlying signal is no longer fresh.
HMAC signatures solve #1 by requiring a shared secret. Timestamped signatures solve #2 by binding the signature to a moment in time, so replays outside a short window can be rejected. If you're hardening the on-chain side of your stack as well, our Solana program security audit checklist covers the smart-contract vulnerabilities that webhook auth alone won't catch.
What MadeOnSol sends
Every copy-trade webhook POST includes three headers:
| Header | Value |
|---|
X-MadeOnSol-Timestamp | Unix millis at signing time, e.g. 1713556200123 |
X-MadeOnSol-Signature | hex(HMAC-SHA256(secret, timestamp + "." + rawBody)) |
X-MadeOnSol-Event | Event type, e.g. copytrade.signal |
The signed string is literally ${timestamp}.${rawBody}. The raw body is the exact bytes of the JSON request — not a re-serialized object. This is the single most common mistake: verifying against JSON.stringify(req.body) after your framework has already parsed it. Keys reorder, spaces vanish, and your HMAC no longer matches.
The shared secret is generated when you create a copy-trade subscription and is visible once in the response. Store it like any other API credential — environment variable, secrets manager, never in code.
Node.js + Express
Express's default express.json() middleware parses and then discards the raw body. We need it back. The cleanest fix is to mount a raw-body parser on the webhook route only, leaving the rest of the app untouched.
import express from "express";
import crypto from "node:crypto";
const app = express();
const SECRET = process.env.MADEONSOL_WEBHOOK_SECRET!;
const MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
app.post(
"/webhooks/madeonsol",
express.raw({ type: "application/json" }),
(req, res) => {
const timestamp = req.header("x-madeonsol-timestamp");
const signature = req.header("x-madeonsol-signature");
const event = req.header("x-madeonsol-event");
if (!timestamp || !signature) {
return res.status(400).send("Missing signature headers");
}
// Reject anything outside the 5-minute window
const age = Date.now() - Number(timestamp);
if (Number.isNaN(age) || age > MAX_AGE_MS || age < -30_000) {
return res.status(400).send("Stale timestamp");
}
// req.body is a Buffer because of express.raw()
const rawBody = req.body.toString("utf8");
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const sigBuf = Buffer.from(signature, "hex");
const expBuf = Buffer.from(expected, "hex");
if (
sigBuf.length !== expBuf.length ||
!crypto.timingSafeEqual(sigBuf, expBuf)
) {
return res.status(401).send("Invalid signature");
}
// Signature verified — safe to parse and act
const payload = JSON.parse(rawBody);
console.log(`[${event}]`, payload.data);
res.status(200).send("ok");
}
);
app.listen(3000);
A few non-obvious details in this handler:
express.raw({ type: "application/json" }) overrides the global JSON parser for this one route. Remove it and your HMAC will never match.
-30_000 on the lower bound of the age check allows for small clock skew between the signing server and yours. Without it, a webhook signed 2 seconds in the future (common with NTP drift) gets rejected.
crypto.timingSafeEqual prevents timing attacks. Never use === to compare signatures — a byte-by-byte string comparison returns fractionally faster on mismatch at later positions, and attackers can exploit that to brute-force one byte at a time.
- Parse after verify, never before. If the body is forged, you don't want its contents anywhere near
JSON.parse or your logic.
Python + FastAPI
FastAPI has the same raw-body-vs-parsed-body issue. Pydantic models want to parse the JSON, but we need the untouched bytes first. Read the raw body directly via await request.body():
import hmac
import hashlib
import json
import os
import time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SECRET = os.environ["MADEONSOL_WEBHOOK_SECRET"].encode()
MAX_AGE_MS = 5 * 60 * 1000
@app.post("/webhooks/madeonsol")
async def madeonsol_webhook(request: Request):
timestamp = request.headers.get("x-madeonsol-timestamp")
signature = request.headers.get("x-madeonsol-signature")
event = request.headers.get("x-madeonsol-event")
if not timestamp or not signature:
raise HTTPException(400, "Missing signature headers")
# Clock-skew tolerant age check
try:
age = int(time.time() * 1000) - int(timestamp)
except ValueError:
raise HTTPException(400, "Bad timestamp")
if age > MAX_AGE_MS or age < -30_000:
raise HTTPException(400, "Stale timestamp")
raw_body = await request.body()
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
raise HTTPException(401, "Invalid signature")
payload = json.loads(raw_body)
print(f"[{event}]", payload["data"])
return {"status": "ok"}
Two details worth calling out:
hmac.compare_digest is Python's constant-time comparison. It's in the standard library — don't write your own.
await request.body() gives you the exact bytes. Using await request.json() first and re-encoding will not match the signature if the producer's JSON serializer emitted any different whitespace or key order.
Testing your handler
The best way to confirm a signature verifier works is to sign a payload yourself and fire it at localhost. Here is a one-off signer you can run to generate matching headers:
import crypto from "node:crypto";
const SECRET = "your_webhook_secret";
const body = JSON.stringify({
event: "copytrade.signal",
data: { token_mint: "So11...", action: "buy", sol_amount: 0.5 },
timestamp: new Date().toISOString(),
});
const timestamp = Date.now().toString();
const signature = crypto
.createHmac("sha256", SECRET)
.update(`${timestamp}.${body}`)
.digest("hex");
console.log(`curl -X POST http://localhost:3000/webhooks/madeonsol \\
-H "Content-Type: application/json" \\
-H "X-MadeOnSol-Timestamp: ${timestamp}" \\
-H "X-MadeOnSol-Signature: ${signature}" \\
-H "X-MadeOnSol-Event: copytrade.signal" \\
-d '${body}'`);
Run it, paste the curl command into your terminal, and you should see a 200 ok. Then flip one character in the signature and confirm you get 401 Invalid signature. If both behave correctly, your handler is ready.