Verifying signatures
Check the signature on every delivery before you trust or parse it.
How the signature is made
Section titled “How the signature is made”X-RaviFurniture-Signature = lowercase_hex( HMAC-SHA256( key = your webhook secret, message = raw request body ) )- Key: your webhook secret, used as text (its UTF-8 bytes). It looks like hexadecimal, but don’t hex-decode it.
- Message: the request body bytes exactly as received. Nothing else is signed: no headers, no URL.
- Result: 64 lowercase hexadecimal characters, with no prefix such as
sha256=.
To verify:
- Read the raw body bytes before any JSON parser touches them.
- Compute the HMAC over those bytes with your secret.
- Compare it with the header using a constant-time comparison. Reject the request (for example with
401) when the header is missing or doesn’t match. - Only then parse the JSON.
Why the raw bytes matter
Section titled “Why the raw bytes matter”We serialise the body with sorted keys, no spaces, and every non-ASCII character escaped. A Telugu product name such as టేకు చెక్క కుర్చీ arrives as \u0c1f\u0c47\u0c15\u0c41 \u0c1a\u0c46\u0c15\u0c4d\u0c15 \u0c15\u0c41\u0c30\u0c4d\u0c1a\u0c40.
If you parse the JSON and serialise it again, you will usually get different bytes, and the signature won’t match. Worse, it can match by luck for simple payloads that contain only ASCII, then fail on the first product name in another script. Always verify the bytes you received.
Node.js
Section titled “Node.js”The sample uses only node:crypto. It compares with timingSafeEqual, and checks the lengths first because timingSafeEqual throws when they differ.
// Verify the signature on a RaviFurnitures webhook delivery (Node.js, no dependencies).//// The X-RaviFurniture-Signature header is the lowercase hex HMAC-SHA256 of the raw// request body, keyed with your webhook secret. Use the secret exactly as you were// given it, as a text string: do not hex-decode it.//// Pass the body bytes exactly as they arrived. Never parse the JSON and serialise it// again before verifying: the bytes would change and the signature would not match.import { createHmac, timingSafeEqual } from 'node:crypto';
export const SIGNATURE_HEADER = 'x-ravifurniture-signature';export const EVENT_HEADER = 'x-ravifurniture-event';
/** * @param {Buffer | Uint8Array} rawBody the request body exactly as received * @param {string | undefined} signature the X-RaviFurniture-Signature header value * @param {string} secret your webhook secret * @returns {boolean} true only when the signature matches */export function verifySignature(rawBody, signature, secret) { if (!(rawBody instanceof Uint8Array)) { // A string here usually means the body was already parsed or re-encoded. throw new TypeError('rawBody must be the raw request body as a Buffer'); } if (typeof signature !== 'string' || typeof secret !== 'string' || secret === '') { return false; } const expected = Buffer.from( createHmac('sha256', secret).update(rawBody).digest('hex'), 'ascii', ); const received = Buffer.from(signature.trim().toLowerCase(), 'utf8'); // timingSafeEqual throws when the lengths differ, so check first. // A wrong length is simply a bad signature. if (received.length !== expected.length) { return false; } return timingSafeEqual(received, expected);}A minimal Express receiver. express.raw() on the route keeps the body as a Buffer; don’t let express.json() run on this route first.
// Minimal Express receiver for RaviFurnitures webhooks.//// npm install express// RAVI_WEBHOOK_SECRET=YOUR_WEBHOOK_SECRET node express-receiver.mjs//// Listens on port 8080 at /hooks/ravifurnitures. Put it behind HTTPS.import { createHash } from 'node:crypto';import express from 'express';import { verifySignature, SIGNATURE_HEADER } from './verify.mjs';
const secret = process.env.RAVI_WEBHOOK_SECRET;if (!secret) { console.error('Set RAVI_WEBHOOK_SECRET to your webhook secret.'); process.exit(1);}
// Keys of deliveries already handled. Every retry of a delivery has a byte-identical// body, so a hash of the body is a safe duplicate key. In production keep these in a// durable store (your database), not in memory.const seen = new Set();
// Replace with your own durable hand-off (a database row, a queue message).async function storeForProcessing(envelope) { console.log('received', envelope.event, envelope.timestamp);}
const app = express();
// express.raw() keeps the body as a Buffer, which is what the signature covers.// Mount it on this route only, so no JSON parser touches the body first.app.post( '/hooks/ravifurnitures', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => { const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0); if (!verifySignature(body, req.get(SIGNATURE_HEADER), secret)) { return res.status(401).send('invalid signature'); }
const key = createHash('sha256').update(body).digest('hex'); if (seen.has(key)) { return res.status(200).send('duplicate'); }
// Parse only after the signature has been checked. const envelope = JSON.parse(body.toString('utf8')); try { await storeForProcessing(envelope); } catch (err) { console.error(err); // Any non-2xx answer makes RaviFurnitures try again later. return res.status(500).send('try again'); } seen.add(key); // Answer quickly: a delivery that takes longer than 10 seconds counts as failed. return res.status(200).send('ok'); },);
app.listen(8080, () => console.log('listening on :8080'));Python
Section titled “Python”The sample uses only the standard library and compares with hmac.compare_digest.
"""Verify the signature on a RaviFurnitures webhook delivery (standard library only).
The X-RaviFurniture-Signature header is the lowercase hex HMAC-SHA256 of the rawrequest body, keyed with your webhook secret. Use the secret exactly as you weregiven it, as a text string: do not hex-decode it.
Pass the body bytes exactly as they arrived. Never parse the JSON and serialise itagain before verifying: the bytes would change and the signature would not match."""
from __future__ import annotations
import hashlibimport hmac
SIGNATURE_HEADER = "X-RaviFurniture-Signature"EVENT_HEADER = "X-RaviFurniture-Event"
def verify_signature(raw_body: bytes, signature: str | None, secret: str) -> bool: """Return True only when `signature` matches `raw_body` under `secret`.""" if not isinstance(raw_body, (bytes, bytearray, memoryview)): # A str here usually means the body was already decoded or re-encoded. raise TypeError("raw_body must be the raw request body as bytes") if not signature or not secret: return False expected = hmac.new(secret.encode("utf-8"), bytes(raw_body), hashlib.sha256).hexdigest() # compare_digest takes constant time for equal-length input and returns False # (without raising) when the lengths differ. return hmac.compare_digest( expected.encode("ascii"), signature.strip().lower().encode("utf-8") )A minimal receiver built on http.server, with the decision logic in a function you can call from any framework:
"""Minimal RaviFurnitures webhook receiver, standard library only.
RAVI_WEBHOOK_SECRET=YOUR_WEBHOOK_SECRET python3 receiver.py
Listens on 127.0.0.1:8080 at /hooks/ravifurnitures. Put it behind an HTTPSreverse proxy."""
from __future__ import annotations
import hashlibimport jsonimport osimport sysimport threadingfrom collections.abc import Callable, Mappingfrom http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from verify import SIGNATURE_HEADER, verify_signature
PATH = "/hooks/ravifurnitures"MAX_BODY_BYTES = 1_000_000
def store_for_processing(envelope: dict) -> None: """Replace with your own durable hand-off (a database row, a queue message).""" print("received", envelope.get("event"), envelope.get("timestamp"), flush=True)
def handle_delivery( raw_body: bytes, headers: Mapping[str, str], secret: str, seen: set[str], on_event: Callable[[dict], None] = store_for_processing,) -> tuple[int, str]: """Decide the response for one delivery. Returns (HTTP status, body text).
Every retry of a delivery has a byte-identical body, so a hash of the body is a safe duplicate key. In production keep the keys in a durable store. """ if not verify_signature(raw_body, headers.get(SIGNATURE_HEADER), secret): return 401, "invalid signature" key = hashlib.sha256(raw_body).hexdigest() if key in seen: return 200, "duplicate" envelope = json.loads(raw_body) # parse only after the signature has been checked on_event(envelope) # if this raises, the caller answers 500 and we get a retry seen.add(key) return 200, "ok"
class Handler(BaseHTTPRequestHandler): secret = "" seen: set[str] = set() lock = threading.Lock()
def do_POST(self) -> None: if self.path != PATH: self._reply(404, "not found") return length = self.headers.get("Content-Length") if length is None or not length.isdigit(): self._reply(411, "length required") return if int(length) > MAX_BODY_BYTES: self._reply(413, "too large") return raw_body = self.rfile.read(int(length)) try: with self.lock: status, text = handle_delivery(raw_body, self.headers, self.secret, self.seen) except Exception as exc: # any non-2xx makes RaviFurnitures retry later print("processing failed:", exc, file=sys.stderr, flush=True) status, text = 500, "try again" self._reply(status, text)
def _reply(self, status: int, text: str) -> None: body = text.encode("utf-8") self.send_response(status) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body)
def main() -> None: secret = os.environ.get("RAVI_WEBHOOK_SECRET", "") if not secret: sys.exit("Set RAVI_WEBHOOK_SECRET to your webhook secret.") Handler.secret = secret server = ThreadingHTTPServer(("127.0.0.1", 8080), Handler) print(f"listening on http://127.0.0.1:8080{PATH}", flush=True) server.serve_forever()
if __name__ == "__main__": main()Getting the raw body in other frameworks
Section titled “Getting the raw body in other frameworks”| Framework | Raw body |
|---|---|
| Express | express.raw({ type: 'application/json' }) on the route, then req.body (a Buffer) |
| Flask | request.get_data() |
| Django | request.body |
| FastAPI or Starlette | await request.body() |
Replays
Section titled “Replays”The timestamp inside the body is covered by the signature, and retries keep the original value. A retry can arrive about ten minutes after the event (see Retries). If you reject old deliveries to limit replays, allow well over that, and rely on de-duplication by body hash as well.
These samples are checked against deliveries signed exactly as the API signs them, including bodies with Telugu text, a tampered body and a wrong secret.