Webhooks
A webhook tells you something happened after it happened. This page is the contract: what arrives, how to prove it came from us, and what we do when your endpoint does not answer.
What it changes
- What arrivesA POST with a JSON body of exactly four fields — eventId, eventType, occurredAt and data — carrying three headers: X-RWAOS-Signature, X-RWAOS-Event-Id and X-RWAOS-Event-Type. The event payload is nested under data rather than spread into the envelope, so an event carrying its own eventType cannot overwrite ours.
- Proving it came from usX-RWAOS-Signature is t=<unix-seconds>,v1=<hex>. The v1 value is HMAC-SHA256 of the literal string `${t}.${rawBody}`, keyed with your endpoint secret, computed over the raw bytes — parse the body after you verify, never before. The timestamp is inside the signed string rather than beside it, so an attacker replaying a captured delivery cannot move it. Reject anything more than 300 seconds old, and compare in constant time.
- Deduplicate on the event idDelivery is at-least-once. A delivery whose response was lost is retried with the same eventId, so treat that id as an idempotency key and discard a second arrival. This is the only thing that makes at-least-once safe for an event that moves money, and it is your side of the contract rather than ours.
- What we do when you do not answerAny 2xx is delivered. 408, 429, 5xx and a network failure are retried five times with backoff at 10s, 1m, 10m and 1h, then recorded as failed. Every other 4xx is not retried at all — your endpoint understood the request and refused it, and a revoked credential will be refused identically on the fourth attempt. 410 Gone deactivates the endpoint entirely: it is the one status that means stop sending, and we honour it.
- Where we will and will not sendEndpoints must be https on port 443 or 8443, with no credentials in the URL. We resolve the hostname and refuse delivery if any resolved address is private, loopback, link-local or carrier-grade NAT — every address, not merely the first, because a host answering with one public and one private answer is a rebinding setup. Redirects are not followed: a 302 to an internal address is the same request with one extra hop.
What it includes
Evidence, not assertion
Controls that have been verified running, rather than described.
- Signature covers `${timestamp}.${body}`, not the body alone.
- Constant-time comparison; a length mismatch returns false rather than throwing.
- Resolved addresses are checked, not the hostname.
- Redirects are never followed.
Questions
Why does my signature check fail on a body that looks identical?
You are almost certainly hashing a re-serialised body. JSON.parse followed by JSON.stringify changes bytes — key order, whitespace, number formatting — and the signature covers the bytes we sent. Capture the raw request body before any parsing middleware touches it.
Do I need to respond quickly?
Yes. Return 2xx as soon as you have stored the event and do your work afterwards. A slow endpoint is retried, and a retry is a duplicate you then have to deduplicate.
What happens if I return 410 by accident?
The endpoint is deactivated and nothing further is sent to it until somebody reactivates it. It is the only status treated that way, precisely because it is the only one that means stop.
See it against something real.
Bring an instrument you are actually considering. Structuring it is the only way to judge whether the model fits.