One GET returns every buy and sell on a pump.fun token, newest first, with the executed price, the pool price at that slot, the wallet and the early-buyer rank. Cursor paging, buy/sell and wallet filters, and what the coverage block tells you before you trust the numbers.
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 re
import time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SECRET = os.environ["MADEONSOL_WEBHOOK_SECRET"].encode()
MAX_AGE_MS = 5 * 60 * 1000
HEX64 = re.compile(r"[0-9a-fA-F]{64}")
@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")
# 64 ASCII hex characters, no prefix. Checking this first also keeps
# hmac.compare_digest from raising TypeError on a non-ASCII header.
if not HEX64.fullmatch(signature):
raise HTTPException(401, "Invalid signature")
if not timestamp.isascii() or not timestamp.isdigit():
raise HTTPException(400, "Bad timestamp")
# Clock-skew tolerant age check
age = int(time.time() * 1000) - int(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.lower()):
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, so don't write your own. It raises TypeError when given a str containing non-ASCII characters, which is why the handler checks the header against a 64-character hex pattern first.
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}'`);