status (pending, delivering, delivered or
failed), attempts and max_attempts, next_attempt_at,
response_status, duration_ms, and error when something went wrong. It is
bounded rather than paginated, because the answer is always near the top.
event_id identifies the domain event; id identifies this delivery of
it. One event fans out to one delivery per subscribed endpoint, and they
share event_id.
Signature verification fails
TRUSTIVAN signs every delivery. Your receiver should verify before doing anything with the body. Four headers arrive with each request:
The signature is HMAC-SHA256 over exactly this byte string:
hmac.Equal in Go, hmac.compare_digest in Python, crypto.timingSafeEqual
in Node. A byte-by-byte == on a MAC lets an attacker recover a valid
signature from response timing. It will also appear to work perfectly in
testing, which is what makes it worth stating.
You are checking only the first signature. The header may carry more than
one signature, space-separated, during a secret rotation. Accept the delivery
if any signature in the header verifies. A receiver that checks only the
first will drop deliveries during every rotation.
A reference verification
raw_body is bytes, taken before parsing.
The destination was refused
Symptom.POST /webhooks returns 400 naming the address class, rather
than “invalid URL”.
A webhook is a request to a destination the customer chooses, made from inside
TRUSTIVAN’s network. That is the definition of server-side request forgery, so
the destination is checked against a deny list of everything that is not
globally routable — loopback, private ranges, carrier-grade NAT, link-local
(which is where cloud instance metadata lives), multicast, reserved and
documentation ranges, and their IPv6 equivalents.
The error names the class — “a private network address”, “a link-local address
— this range hosts cloud instance metadata services” — because “10.0.0.5 is a
private network address” is actionable and “invalid URL” is not.
Two things are worth knowing:
The check at registration is the shallow half. The real control runs at
connection time, on the resolved address, and it applies to redirects too —
the classic bypass is a public hostname that responds with a 302 to the
metadata service. A destination that passes registration can still be refused
at delivery time if its name resolves somewhere blocked.
WEBHOOK_ALLOW_PRIVATE_DESTINATIONS=true permits private and loopback
destinations. It exists so a developer running a receiver on localhost, and an
end-to-end test running one on 127.0.0.1, exercise the real delivery path
rather than a bypassed one. Configuration validation refuses to start a
production deployment with it set. If your local receiver is being refused, this
is the setting; if you are reaching for it in production, the destination is
the thing to change.
Destinations must be HTTPS URLs of at most 2048 characters.
Deliveries are being retried
A delivery succeeds on a 2xx. Anything else — a 4xx, a 5xx, a connection failure, or no response within the 10-second whole-request budget — is a failure and is retried. Retries are exponential from a 30-second base, doubling, capped at 2 hours, for up to 20 attempts — roughly a day of failures in total. Then the delivery is markedfailed and abandoned.
Exponential rather than fixed, because the common failure is an endpoint that
is down for a while, and a fixed interval turns that into a steady stream of
pointless requests at a receiver that is already struggling.
The 10-second budget covers connect, write and read. A receiver that has not
answered in ten seconds is not going to answer usefully, and a slow one must
not be able to hold a delivery worker open — that is how one customer’s broken
receiver becomes every customer’s delivery backlog. Acknowledge fast and work
afterwards: return 2xx as soon as you have verified the signature and durably
enqueued the payload, then do the real work outside the request.
An endpoint that keeps failing is eventually disabled. Re-enabling it clears the
consecutive-failure count, so an operator who has fixed their receiver is not
disabled again by the failures that preceded the fix.
Consumers must be idempotent
At-least-once delivery is a guarantee, not an accident. The same event can arrive more than once, and you must handle it. The unavoidable case: your receiver processes a delivery successfully, and the acknowledgement is lost — a connection reset, a timeout on the way back, a load balancer that gave up. TRUSTIVAN has no way to distinguish that from a receiver that never got the request, and the only safe assumption is that it did not arrive. So it retries. The alternative would be at-most-once, which drops events. For a security product, silently losing “a critical vulnerability appeared in production” is a far worse failure than delivering it twice. Deduplicate onTrustOwl-Delivery. Record the ID, in durable storage, at
the point you finish processing; treat a delivery whose ID you have already
recorded as a no-op and return 2xx. Use TrustOwl-Delivery rather than
event_id if you want per-delivery semantics; use event_id if you want to
process a domain event once regardless of how many endpoints it fanned out to.
Ordering is not guaranteed either. A retried delivery arrives after events that
were generated later, so make your handler tolerant of that: prefer fetching
current state over reconstructing it from an event sequence.
Rotating a secret without dropping deliveries
secret — the only time it is returned — and
previous_secret_expires_at, when the old one stops being used. The overlap is
24 hours.
During the overlap every delivery is signed with both secrets, and the
TrustOwl-Signature header carries both, space-separated. That is what makes
rotation a change you can schedule rather than an outage: a secret that took
effect the instant it was created would drop every delivery until your receiver
was reconfigured.
The roll, in order:
- Confirm your receiver accepts any signature in the header, not just the first. Do this before rotating, not during.
- Rotate. Store the new secret from the response immediately — there is no read-back path, and no endpoint that will ever show it again. Signing secrets are encrypted at rest rather than hashed, because TRUSTIVAN must reproduce them to sign, but nothing reads them out.
- Deploy the new secret to your receiver, within the 24 hours. If your receiver can hold two secrets, configure both and verify against either; otherwise swap to the new one, which is safe because deliveries are still signed with it.
- Watch
GET /webhooks/deliveriesfor a few deliveries after the swap, confirmingstatus: "delivered"and 2xx responses.
webhooks:manage is required to rotate, and it is not grantable to an API
key, so rotation is a human action through a browser session. An endpoint is a
destination every future event is copied to, so a machine credential that could
add or repoint one could stream your findings somewhere you never chose.
Where to go next
- Credential handling — why webhook secrets are encrypted while API keys are hashed.
- Scopes —
webhooks:readand what it excludes. - Security model — the rest of the outbound-request boundary.

