Register an endpoint
write scope. Subscribe to * to receive the whole catalogue. An unknown event type is rejected here, so a typo fails at registration rather than silently never firing. A subscribed type that no producer publishes on this deployment comes back in inactiveEventTypes for the same reason.
The event catalogue
Every delivery carries the same envelope.data holds identifiers and small scalars only: never node content, never a proposal body, never an identity blob.
Keys marked “when known” appear only when the producer has the value. Treat
data as additive and ignore keys you do not recognise.
brain.trained is observed rather than emitted at the moment of completion. The train finishes on a separate worker, and the event lands on the next sweep. It is an honest observer, not an instant emit.Verify the signature
Every delivery carries four headers:
The
v1 value is HMAC_SHA256(secret, "<t>." + raw_body), hex encoded.
Two rules decide whether your verification is correct:
- Sign the raw bytes, not a re-serialized object. Re-encoding JSON changes key order and whitespace, and the signature will not match.
- Compare in constant time. Use your language’s timing-safe comparison, never
==.
Test before you rely on it
POST /v1/webhooks/{webhook_id}/test sends a signed webhook.test event now and returns the outcome inline, so you can confirm verification works before a real event depends on it.
A test never counts against the endpoint: it is recorded in the delivery log, and it never contributes to the failure streak, never auto-disables the endpoint, and is never retried. If your receiver rejects the event, the call still returns 200, with ok: false and the status your endpoint responded with, because the call succeeded even though the delivery did not.
Retries and auto-disable
Retries are fire and forget: the emitter is never blocked and never sees the outcome. Jitter exists so that a receiver coming back up is not hit by every endpoint’s retry in the same instant.
After 8 consecutive failed attempts an endpoint auto-disables and stops receiving. Inspect what happened with
GET /v1/webhooks/{webhook_id}, which returns the recent attempts newest first with the response status and a short response snippet, so a broken receiver can be diagnosed without server-side logs.
Re-enable it once the receiver is healthy:
disabled to false also clears the failure streak. The signing secret is unchanged.
Registering a twenty-first endpoint is 409 webhook_limit_reached. Fan-out is serial with a per-endpoint timeout, so an unbounded list would be both a latency problem for you and a way to turn one organization into an outbound request amplifier. Subscribe one endpoint to more event types rather than registering another.
URL restrictions
Exo validates a webhook URL at registration and again at every delivery. This is deliberate: a hostname that resolves to a private address today could resolve somewhere else tomorrow.- The scheme must be
https. - No
user:pass@userinfo, and the default https port. - The URL is at most 2048 characters.
- The hostname must resolve to a public address. Private, loopback, link-local, CGNAT, reserved, multicast, unspecified and tunnelled ranges are all rejected, including
10/8,172.16/12,192.168/16,127/8,169.254/16,100.64/10,::1,fc00::/7andfe80::/10. The embedded IPv4 address inside a 6to4 or Teredo address is unwrapped and checked as well. - Redirects are never followed, so a
302to a private address cannot launder the check.
One limit stated plainly: the resolved address is checked but is not pinned into the connection, so a DNS rebind between the check and the connect is theoretically possible. Closing that needs a transport that dials the verified address directly, which is a follow-up rather than a v1 behaviour.
Who receives what
Registering an endpoint requireswrite, and an endpoint is organization-level configuration, so a subscription is not by itself a grant to read every member’s data.
Four events are about a person rather than about the organization: job.completed, basin.shifted, contradiction.detected and belief.superseded. Delivery of those drops endpoints whose creator could not read that person’s data through the API, which matches how the REST surface already behaves. One exemption matches REST exactly: a provisioned subject stays visible organization-wide, because any member key can already read that partition by sending X-Exo-Subject.
graph.updated also carries a subject and is deliberately organization-wide. Its reach predates the narrowing, so restricting it is a separate decision rather than a fix, and it is recorded here rather than hidden.
Rotation
There is no rotation route in v1. To rotate a secret, register a new endpoint and delete the old one. That is a deliberately smaller surface than a half-built rotation flow.Errors
Related
Events
The live SSE stream, its heartbeat, and its per-organization cap.
Events and webhooks
Every route in the family, with request and response schemas.