Webhook signing for SMM panels: HMAC-SHA256 in production
Why webhook signing matters for SMM panel integrations, how HMAC-SHA256 with a timestamp prevents both forgery and replay, and a working Node.js verification implementation.
Webhook security is the part of an SMM integration most teams skip until something goes wrong. The default — receive POSTs at a public URL and trust anything arriving there — is fine when nothing has gone wrong. The first time someone fabricates webhook deliveries against you, the cost of skipping signing becomes clear.
What can go wrong without signing
Your webhook URL is publicly reachable by definition. Anyone who guesses or scrapes it (and most webhook URLs are predictable — /webhook, /notpanel-webhook, subdomain patterns) can send fabricated requests. Without signature verification, your code can't tell the difference. Specific failure modes:
- Fake order completion. Attacker forges a payload claiming order #123 completed. Your code marks it complete in your downstream system. The customer sees a completed order; the underlying provider hasn't delivered anything.
- Fake refund. Attacker forges an
order.refundedevent. Your code refunds the customer. You're now paying out phantom refunds against orders that didn't actually fail. - Fake balance alerts. Attacker spams
balance.lowevents. Your auto-top-up logic, if any, kicks in repeatedly. - Replay attacks. Even with signing, if you don't check the timestamp an attacker who captures one valid request can replay it. The replay is valid by signature alone; only the timestamp would expose it.
How HMAC-SHA256 fixes this
HMAC (Hash-based Message Authentication Code) is a cryptographic primitive that combines a shared secret with a message to produce a fixed-length signature. Both sides — sender and receiver — know the secret. The sender computes the signature over the message body and attaches it to the request. The receiver computes its own signature over the body and compares.
The receiver doesn't need to share the secret with anyone but the sender. The signature can't be forged without the secret. As long as the secret stays secret, only the genuine sender can produce signatures that verify.
The full contract
A robust webhook signing scheme has four ingredients:
- Per-endpoint secret. Each registered webhook gets its own. If one secret leaks, the blast radius is limited to that endpoint.
- HMAC-SHA256 of the raw request body. Computed server-side, sent as a header (e.g.
X-NotPanel-Signature). - Timestamp header. Unix seconds at send time. The receiver checks the signature AND that the timestamp is recent — typically within ±5 minutes of the receiver's clock.
- Constant-time comparison. Comparing signatures with
===leaks timing information. Usecrypto.timingSafeEqualin Node, or the equivalent in your language.
Reference verification (Node.js)
import { createHmac, timingSafeEqual } from "node:crypto";
interface VerifyParams {
rawBody: string;
signatureHeader: string | null;
timestampHeader: string | null;
secret: string;
/** Tolerance window in seconds. Default 5 minutes. */
toleranceSec?: number;
}
export function verifyWebhook({
rawBody,
signatureHeader,
timestampHeader,
secret,
toleranceSec = 300,
}: VerifyParams): boolean {
if (!signatureHeader || !timestampHeader) return false;
const ts = Number.parseInt(timestampHeader, 10);
if (!Number.isFinite(ts)) return false;
const nowSec = Math.floor(Date.now() / 1000);
if (Math.abs(nowSec - ts) > toleranceSec) return false;
const expected = createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
let received: Buffer;
try {
received = Buffer.from(signatureHeader, "hex");
} catch {
return false;
}
const expectedBuf = Buffer.from(expected, "hex");
if (received.length !== expectedBuf.length) return false;
return timingSafeEqual(received, expectedBuf);
}Wiring it into an Express handler
import express from "express";
import { verifyWebhook } from "./verify-webhook";
const app = express();
// Capture the raw body BEFORE any JSON parsing. The signature was computed
// over the raw bytes; re-serialising the parsed object will not match.
app.post(
"/notpanel-webhook",
express.raw({ type: "application/json" }),
(req, res) => {
const ok = verifyWebhook({
rawBody: req.body.toString("utf-8"),
signatureHeader: req.header("X-NotPanel-Signature"),
timestampHeader: req.header("X-NotPanel-Timestamp"),
secret: process.env.NOTPANEL_WEBHOOK_SECRET!,
});
if (!ok) {
return res.status(401).json({ error: "Invalid signature" });
}
const event = JSON.parse(req.body.toString("utf-8"));
// ... handle event
res.status(200).json({ received: true });
},
);The "raw body" gotcha
Most JSON-parsing middleware reads the request body and discards the original bytes. By the time your handler runs, you have a parsed object — but the signature was computed over the original bytes, including specific whitespace and key ordering. Re-serialising the parsed object will produce different bytes, a different signature, and spurious verification failures.
Capture the raw body before any JSON parsing. In Express, that's express.raw() instead of express.json(). In Next.js route handlers, read request.text() and verify before parsing. In Fastify, use rawBody. Same principle across frameworks: don't let the JSON parser touch the body until the signature is verified.
Half of all "signature mismatch" bugs are the JSON parser eating the body before the verifier sees it.
Replay attacks and why timestamps matter
Without a timestamp check, an attacker who captures one valid webhook delivery — say, by intercepting a single request through a misconfigured proxy or a compromised log — can replay it indefinitely. The signature is valid; only timing would betray the replay.
A 5-minute tolerance window is the standard. Shorter windows risk rejecting legitimate deliveries when clocks drift; longer windows give attackers a bigger replay window. Five minutes balances the two for most use cases.
For extra paranoia, deduplicate event IDs at the receiver. Webhook events typically have a stable ID — remember the ones you've already processed (in Redis, with a TTL longer than your tolerance window) and reject duplicates. This catches both replays and accidental re-deliveries from the sender.
Of integrations using NotPanel webhooks, what's the breakdown of verification implementations?
Rotating secrets
Periodic rotation is good hygiene. The mechanics:
- Register a second webhook endpoint with a new secret.
- Switch your downstream consumer to verify against either the old or new secret (check both, accept if either matches).
- Wait long enough for in-flight deliveries to settle (24 hours is plenty).
- Remove the old endpoint.
- Switch the consumer to verify only against the new secret.
Most panels don't expose direct "rotate secret" semantics. The register-then-deregister pattern above approximates the same outcome without needing API support for in-place rotation.
What "good enough" looks like
For a typical SMM integration: a determined attacker who has read your public site, scraped your webhook URL, and intercepted some traffic should not be able to forge a valid delivery. HMAC + timestamp + constant-time compare gets you there.
The signing implementation in NotPanel's developer documentation (see /developers/webhooks) covers the exact header names and signature format. The verification code above works against it as-is.