Skip to main content
Listo sends webhooks to a URL you gave it, with no credential in the request. The signature is the credential. It is what tells you an event came from Listo, arrived unaltered, and is not one somebody captured earlier and sent again. Every event carries one header:
  • t is the Unix time, in seconds, at which the event was signed.
  • v1 is a hex HMAC-SHA256, keyed on your signing secret, over the string "<t>.<raw body>".
Note that t is inside the signed string, not merely sitting beside it. That is the whole point of this scheme over a plain HMAC of the body: a plain one stays valid forever, so anyone who ever saw a single valid request could resend it whenever they liked. Binding the timestamp into the digest means a replay has to either keep the original t, and fall outside the window below, or change it, and invalidate the signature.

Verifying one

verify_webhook_signature.rb
Four rules, and each of them is a way the check can be wrong while still looking right. Hash the raw bytes. Sign what arrived on the wire, before any JSON parsing. Parsing and re-serialising changes the bytes, because your JSON library has its own ideas about key order, escape sequences and whitespace, and the signature covers what was sent rather than what your library would have sent. Most web frameworks give you the raw body if you ask; find out how yours does before writing the check. Check the timestamp in both directions. Reject anything more than five minutes old, which is the replay this scheme exists to stop, and anything more than five minutes in the future. Skipping the second half hands an attacker an arbitrarily long window by simply choosing a large t. Compare in constant time. Use your language’s constant-time comparison, not ==. A comparison that returns early on the first differing byte leaks how much of a guess was right, which is enough to walk out a valid signature one byte at a time. Fail closed. A malformed header, a missing header, an unparseable timestamp and a signature that does not match are all the same answer: not authentic. Reject with a 400, and do not parse or act on the body. Never let the verifier raise; an exception in a verifier is a 500 where a refusal belonged.

Rotating a secret

You can have two secrets live at once, which is what makes a rotation possible without dropping events. Listo signs with the primary one; you accept either.
  1. Add a second secret in the partner dashboard. Nothing changes yet: Listo keeps signing with the first.
  2. Teach your verifier both. This is why the check above takes a list: accept an event that validates under either secret.
  3. Promote the new secret. Listo starts signing with it. Your endpoint keeps working through the switch, because it already accepts both.
  4. Drop the old one, in the dashboard and in your own configuration.
Do them in that order. Promote before step 2 and every event in flight is rejected by an endpoint that has never seen the new secret.
A secret is a secretStore signing secrets the way you store passwords: server side, out of your repository, out of anything a browser can reach. Anyone holding one can forge an event your verifier will accept, which is the same as being able to tell your system that an order was delivered.

Modes have their own secrets

Live and test each have their own endpoint URL and their own signing secret. A secret from one mode never validates an event from the other, which is what stops a staging deploy from being able to accept production traffic. Rotate them independently. Putting all of this to work, including responding, deduplicating and retries, is receive and verify webhooks.