Engineering

Verify the bytes, not the object

A webhook endpoint is a public URL, with no authentication, that accepts claims about money. Anyone who learns the address can post to it, and the claim will say a collection settled.

The signature is the only thing standing between that and a credited balance. Which makes the order of operations in your handler a security property, not a style preference.

Sign the bytes that arrived

We compute an HMAC-SHA512 over the raw request body using your webhook secret and send it in x-figo-signature. You recompute it and compare.

const expected = crypto
  .createHmac("sha512", webhookSecret)
  .update(rawBody) // the exact bytes we sent
  .digest("hex");

The comment is doing the work. The exact bytes. Not the object you parsed, not the object re-serialised, not a body your framework helpfully normalised on the way in.

A JSON round trip is not identity-preserving. Key order can change. Number formatting can change — 32.5 may come back as 32.50. Unicode escaping and whitespace can change. Every one of those produces different bytes and therefore a different digest, and the signature that was perfectly valid now fails.

This is the bug that costs an afternoon, because the failure looks like a signing problem on our side rather than a body-parser problem on yours. Most frameworks parse JSON before your handler ever runs, so capturing the raw body usually means opting out of that for this one route.

Compare in constant time

Comparing two digests with === returns as soon as it finds a differing character. That timing is measurable over enough requests, and it leaks the expected value one character at a time.

Use your runtime's constant-time comparison. It is a one-line change and the argument against it is always that nobody would actually do that, which has historically not been a good bet.

Then check what it says, not just that it is signed

A valid signature proves the message came from us. It does not prove the message is about something you should act on.

Every delivery carries the event name in x-figo-event and the transaction in the payload. Confirm the event is one you handle, that the transaction belongs to you, and that the state it describes is a legal move from the state you already hold — a collection.completed for something your records already show as failed is a signal to investigate, not to credit.

Store, acknowledge, then work

The rule is three steps in that order: persist the raw event, return 2xx, process afterwards.

Anything that is not a 2xx — including a response that is merely slow — tells us the event did not arrive, and we retry. So doing the real work inside the request has a specific failure mode: a downstream dependency gets slow, your handler blocks, we time out and retry, and now the slow work is running twice concurrently. Load causes duplication precisely when you can least afford it.

Acknowledging means received, not handled. Once that is the contract, a slow dependency produces a queue instead of a retry storm.

Assume every event arrives twice

Retries are one source of duplicates. Deliberate replay is another — every delivery is recorded and any of them can be sent again from the dashboard, which is the tool you will want during an incident.

That tool is only usable if replaying is harmless. Key your handler on the event, take a uniqueness constraint before doing anything, and derive state from the transaction rather than from the payload — the argument in a status pill is not a state machine. Handlers built that way can be replayed on purpose.

It is the same property idempotency keys give the write path, applied to the read path: safe to run twice, by construction.

Rotate as a matter of course

The secret is shown once, when you set the endpoint. Treat it like any other credential: out of source control, out of logs, and rotated when anyone who should not have seen it might have.

Worth knowing before you need it: your handler should tolerate a window where either the old or the new secret validates, so rotation does not mean dropping deliveries in the gap between updating your side and ours.

Event list and payload shapes are in the docs. Questions: hi@spendfigo.com.

Common questions

How do I verify a webhook signature?

Recompute an HMAC of the exact raw request body using your webhook secret, then compare it to the signature header. The comparison must happen before the body is parsed or otherwise transformed.

Why must webhook signatures be verified against the raw body?

Because the signature covers the exact bytes that were sent. A JSON parse and re-serialise can reorder keys, change number formatting or alter whitespace, and the resulting bytes will not match a signature that was valid.

Should a webhook handler do its work before responding?

No. Store the event, return 2xx immediately, then process asynchronously. A slow response is treated as a failed delivery and triggers a retry, so working inside the request turns a slow dependency into duplicate deliveries.

← All engineering writing