Skip to main content
POST
Register a webhook endpoint
The response contains the signing secret. It is shown once, here, and is never retrievable again: store it before you close the response. Verify each delivery by recomputing HMAC_SHA256(secret, "<t>.<raw body>") and comparing it to the v1 value in the X-Exo-Signature header. The url must be https and must resolve to a public address: private, loopback, link-local, CGNAT, reserved and tunnelled ranges are rejected at registration and again at every delivery, and redirects are never followed. Unknown event types are rejected so a typo fails here rather than silently never firing; a subscribed type that no emitter publishes yet comes back in inactiveEventTypes for the same reason. An organization may register at most 20 endpoints (409 webhook_limit_reached beyond that). A retried Idempotency-Key returns the original registration but with the secret field replaced by a marker: the plaintext is never persisted anywhere, including the replay store. WHAT AN ENDPOINT RECEIVES. Registering does not widen what the key behind it can see. Events about the ORGANIZATION (insight.proposed, brain.trained) are delivered to every endpoint. Events about a PERSON (job.completed, basin.shifted, contradiction.detected, belief.superseded) are delivered only to endpoints registered by that person, mirroring the API, where another member’s job returns 404 and identity reads are self-or-admin. Events about a PROVISIONED SUBJECT are delivered to every endpoint, because any key in the organization can already read that subject’s data with X-Exo-Subject.

Authorization

This route requires the write scope. A missing or invalid credential returns 401 authentication_error. A valid credential without the scope returns 403 permission_denied, and the problem body names the exact scope required.

Headers

These are request conventions the contract does not declare as parameters, so they do not appear in the schema tables below.

Success responses

Errors

Beyond the shared statuses, this route can answer with webhook_limit_reached, idempotency_in_flight, idempotency_key_reuse. Every problem body names its own code, and the type URI always resolves to the matching page. Every error is an RFC 9457 application/problem+json body carrying a stable code, a requestId, and a suggestedAction where Exo has one. See Errors.

Notes

  • The signing secret is in this response and nowhere else. No route returns it again. Store it before you close the connection.
  • A replayed Idempotency-Key returns the registration with the secret redacted, not in the clear. The replay still proves the endpoint was created once.
  • The URL must be https and must resolve to a public address. Private, loopback, link-local, CGNAT, reserved and tunnelled ranges are rejected at registration and again at every delivery, and redirects are never followed.
  • Unknown event types are rejected here so a typo fails at registration rather than silently never firing.

Authorizations

X-Exo-API-Key
string
header
required

An Exo API key (exo_...) sent as the X-Exo-API-Key header.

Body

application/json

Body for POST /v1/webhooks.

eventTypes
string[]
required

Event types to subscribe to, or ['*'] for every event.

Required array length: 1 - 20 elements
url
string
required

HTTPS endpoint that will receive signed event deliveries.

Maximum string length: 2048

Response

Successful Response

Registration response: the ONLY place the signing secret is returned.

consecutiveFailures
integer
required
createdAt
string<date-time>
required
disabled
boolean
required
eventTypes
string[]
required
id
string
required
secret
string
required

The signing secret. Shown once, at registration, and never retrievable again -- not by a read route and not by an idempotent replay, which returns a marker in its place. Store it now; if it is lost, register a new endpoint.

url
string
required
disabledReason
string | null
inactiveEventTypes
string[]

Subscribed event types that no emitter publishes yet, so nothing will be delivered for them. The subscription is kept and starts delivering as soon as the emitter ships; this field exists so you never wait on an event silently.

lastDeliveryAt
string<date-time> | null
lastSuccessAt
string<date-time> | null
updatedAt
string<date-time> | null