The fourth time this came up in the community Slack it was the same bug wearing a different framework. Handler rejecting every delivery, secret correct, algorithm correct, and the line doing the damage never touched crypto at all. The web framework had already parsed the body, and the handler was signing a re-serialisation of the parse.
So here's the whole path, including the boring parts. This is what lands on your endpoint:
The signature is an HMAC-SHA256 over the raw body bytes, hex encoded, with sha256= glued to the front. The key is your endpoint's signing secret, the entire string, whsec_ prefix included. Plenty of people strip that prefix because it reads like a label rather than key material. It is key material. Strip it and every delivery fails verification with no other symptom, which is a miserable afternoon.
Start with the version that doesn't work
First, json.dumps(json.loads(x)) is not x. Python's default separators put a space after every colon and comma; the sender's serialiser puts none. Fix the separators and you're still exposed to key ordering, unicode escaping and float formatting, any one of which flips a byte and therefore flips all 32 bytes of the digest. Sign what arrived on the socket, and nothing else.
Second, the header value is sha256=9f2b..., so that comparison runs against a string seven characters longer than anything hexdigest() will ever return. It fails even when the crypto is right.
Third, != on two strings returns as soon as it finds a difference. That is a timing oracle in your handler. Someone who can post to your endpoint repeatedly and time your 401 can walk the expected signature forward a byte at a time, and at the end of that they hold a valid signature for a body they chose, without ever having seen your secret. Over the public internet, with jitter, the attack is awkward. It is also completely avoidable, and the avoidance is one function call.
Six lines, three languages
Python first, with no framework in it:
compare_digest is the constant-time one. Getting raw_body is the step people lose an hour on, and it differs per framework: Flask gives you request.get_data(), FastAPI gives you await request.body(), and Django gives you request.body but raises RawPostDataException if anything has already touched request.POST. Read the bytes before you parse, always.
TypeScript, same shape:
The length check isn't decoration. timingSafeEqual throws a RangeError on buffers of different sizes, so a two-character signature would otherwise get you a 500 instead of a 401. It also covers the other trap: Buffer.from("zzzz", "hex") doesn't throw, it returns an empty buffer. On Express, mount express.raw({ type: "application/json" }) on this route alone and call JSON.parse(req.body) after verifying. If a global express.json() ran first, req.body is an object and the bytes are gone.
Go, for the same reason:
io.ReadAll(r.Body) before you unmarshal, and note that hmac.Equal is the constant-time comparison while bytes.Equal is not. Ruby and Rust versions exist and they're the same six lines with different spelling, so I'd rather spend the space on what you do after the check passes.
You now have a handler that drops anything not signed with your secret and parses only what survives.
Test it before you point anything real at it. Save a body to a file, sign it with the same secret at the shell, and post it to yourself:
printf rather than echo, because echo appends a newline and that newline is inside the bytes you signed but outside the bytes curl sends, or the other way round depending on your shell. You'll get a 401 and spend twenty minutes blaming your HMAC. Then flip one character of $BODY without re-signing and confirm you get the 401 you're supposed to get. A verifier that has only ever been tested against valid input isn't a verifier yet, it's a decoder.
Dedupe on the body id, like you would for any webhook
Every payload carries an id at the top level. That's the feed item id, it sits inside the signed bytes, and it's stable. Any webhook consumer worth deploying is idempotent by that id, whatever the source: you write it to a table with a unique constraint, you act only when the insert is new, and a second copy of the same item costs you a lookup instead of a duplicate order. This is ordinary hygiene for HTTP delivery, not a Forecite quirk. Networks retry, proxies retry, and your own restart in the middle of processing will happily hand you the same item twice.
Keep those ids for as long as your business logic needs to consider two events the same event. A day is generous for most desks. An hour is fine if you only care about not double-firing inside a session.
- 1Read the raw request bodybefore any JSON parsing touches it
- 2Recompute HMAC-SHA256 with your signing secretfull whsec_ string as the key
- 3Compare in constant time against the headerstrip the sha256= prefix first
- 4Look up the payload idalready handled means acknowledge and stop
- 5Acknowledge, then do the work off the request pathqueue it, don't block the response
That last step is the one people leave until it hurts. Your handler should return a 2xx as soon as verification and the dedupe check pass, then put the real work on a queue. Position sizing, order routing and anything touching a broker belong behind that boundary, because the slowest thing you do inside an HTTP handler eventually becomes the thing that decides whether you got the item at all. A failed verification stays a 401. Failing closed on a payload you cannot authenticate is the right call every time, given what you're going to do with the numbers inside it.
Last thing, and it costs you one line. Log the X-Forecite-Delivery uuid next to your verify result, pass or fail. It's the value that exists on both sides of the wire, and when you post in Slack saying you think you missed Tuesday's 13:31 item, it's what lets us line your log up against ours.