BUILD · WEBHOOKS
Events, HMAC, and three ways to get it wrong.
A webhook registry and an HMAC dispatcher are real code in this platform, with their own test suite and their own security tests — but they sit behind the operator plane, so an anonymous reader of this page cannot register a destination or pull a live delivery receipt. What this page can do honestly is teach the three ways a receiver of any HMAC-signed webhook — this one or anyone else's — gets the verification wrong, because that is checkable engineering knowledge, not a claim about what you can see today.
An HMAC signature only proves what you actually checked. Each failure below is a place where "checked" turns out to mean less than it looks like.
One — replay, when the signature carries no timestamp
An HMAC over the body alone proves the body was not altered. It proves nothing about when it was sent. Capture a genuine, correctly signed delivery once and you can resend it forever — the signature still checks out, because nothing in it says "not after". The fix binds a timestamp into the MAC itself, so an attacker cannot advance the clock without invalidating the signature, and the receiver refuses anything outside a tolerance window.
This platform's own dispatcher actually sends two signatures on every
delivery, and the difference between them is this exact lesson made concrete. The legacy
x-orbis-signature header is sha256=<hex> over the body
alone — no timestamp bound in, kept only because an existing consumer already depends on
that exact shape. Beside it, x-orbis-signature-v2 carries
t=<unix-seconds>,v1=HMAC-SHA256(secret, `${t}.${body}`), and a
x-orbis-timestamp header states the same seconds in the clear. A receiver
that verifies only the legacy header has verified nothing about replay — it must check
x-orbis-signature-v2 and refuse anything outside a
300-second window to get the property this section describes.
Two — comparing signatures with ===
A naive string comparison returns as soon as the first byte differs, and that timing
difference is measurable over a network by an attacker with enough attempts. The correct
comparison runs in constant time regardless of where the strings diverge — Node's
timingSafeEqual, or the equivalent in any other runtime, over two buffers of
equal length. A comparison that returns early is not a security check; it is a timing
oracle wearing a security check's clothes.
Three — verifying a re-serialized body instead of the raw bytes
The signature was computed over exact bytes on the wire. Parse the JSON, re-serialize it with a different key order, different whitespace, or a different number formatting, and you are computing a MAC over a body the sender never signed — it will not match, or worse, it will match by coincidence on the cases you happened to test. The signature must be checked against the literal request body, before any framework middleware has touched it, and only parsed afterward.
What this platform's dispatcher actually does
Deliveries are jobs, not timers — any replica can claim one, so nothing depends on a
single instance staying up. A destination is refused at registration if it is not
https, resolves to loopback, or points at a special-purpose internal
address — an SSRF check that runs before the URL is ever stored, not only at delivery
time. A failed delivery backs off over [30, 120, 600, 1800] seconds and is
dead-lettered after 5 attempts. All of it sits under the same
authenticated plane the generated contract declares as BO — see
the generated contract for how routes are grouped by plane in
general.
The delivered body is { id, type, occurredAt, data } — there is no
dedicated delivery-id header on this build, so a receiver dedupes on that id
field rather than on a header. And measured against this repository's own dispatch code
rather than assumed: exactly one event type is wired to fire anywhere in this
platform today — item.accepted, dispatched when a holder accepts an offered
wallet item. The Back Office endpoint-registration screen's own placeholder text suggests
names like credential.issued and credential.revoked as
examples of what an operator could type when filtering a destination's events — that
placeholder is not a claim those events are ever dispatched, and this build finds no
code path that fires either one.
What is real here.
A webhook registry and dispatcher are implemented and tested in this platform's own suite. No public, unauthenticated endpoint for registering or reading a delivery exists, so this page cannot hand you a command to run — only the engineering the dispatcher is built on.
The register holds 17 live · 2 partial · 2 planned · 5 not yet.
1 of the 1 capabilities this page depends on have no row in the register yet, so this page will not print a state for them. They are named rather than dropped, because a slice that silently shortens itself is the same defect as a claim with no receipt.
- generated-contract
The register route serves, but it carries no row for these yet. List what it does carry:
curl -s https://id.orbis.id/api/site/register | jq -r '.entries[].slug' Straight answers
- Can I register a webhook from this page?
- No. Registration sits behind the operator plane; this page names the mechanism, not a self-service form.
- Is HMAC-SHA256 alone enough?
- No — without a timestamp bound into the MAC, a captured delivery can be replayed indefinitely.
- Does checking the signature protect against a rebinding attack on the destination host?
- No, not by itself. That is a separate, delivery-time check against the resolved address — the signature only protects the body.
- Is there a header I can dedupe deliveries on?
- No dedicated one. Dedupe on the
idfield the delivered JSON body already carries. - Does this platform fire a
credential.issuedwebhook event today? - No. Measured against the dispatch code, exactly one event type is wired to fire anywhere in this build —
item.accepted.
Do not trust us. Check us.