Skip to main content
Vexa posts signed JSON to a URL you own when a meeting’s state changes. Configuration is self-serve over the API, and every delivery attempt is recorded in a history you can read back.

Configure

PUT /user/webhook
string
required
Destination URL. Pass "" to clear it — null is rejected with 422.
string
Shared secret used to sign each payload. Omit to leave the stored secret unchanged.
object
A map of event name to boolean, e.g. {"meeting.completed": true}. An array is rejected with 422. Omit to leave the stored selection unchanged; when no selection is stored, the default events fire.
GET /user/webhook
Response
The secret is masked to its last four characters on read-back — enough to recognize which secret is set, never enough to use it.
Delivery works; the delivery history does not. A production walk on 2026-07-19 (v0.12.15) delivered meeting.status_change, meeting.completed and bot.failed to an external webhook.site endpoint — the receiver’s inbox and meeting-api’s own delivery-outcome log matched one-for-one at code=200. What is still broken is the user-facing Delivery History, which reads a store those deliveries were never written to and so shows none of them (#841, open). Deliveries now succeed silently — the exact inversion of #815, where they failed silently.
PUT /user/webhook currently answers with the whole user record, including max_concurrent_bots and the account’s Stripe customer, subscription and period fields — not the webhook shape GET returns. It is your own record, not another tenant’s, but do not build against those fields: they are leaving this response. Tracked as #1241.

Events

The typed events are additive: meeting.status_change still fires for the same transition.

Signature

Each delivery carries Content-Type: application/json and, when a secret is set: Verify by recomputing the HMAC over "<X-Webhook-Timestamp>." + raw_body and comparing in constant time. Reject timestamps outside your replay window.

Delivery history

GET /user/webhook/deliveries
Response
Newest first. limit defaults to 100 and accepts 1–500; the ledger keeps the most recent 100 attempts per account, so it is a recent window rather than a permanent audit log. outcome is one of delivered · queued · suppressed · blocked · failed. status_code is what your endpoint answered, when it answered. A row carries the host only — never the configured URL, never the secret.
That is the contract. Today the store behind it is not the one real deliveries are written to (#841, open), so this endpoint can answer with an empty deliveries array for an account whose webhooks are in fact arriving. Do not read an empty history as “nothing was delivered.”
Don’t have a key yet? Hosted: sign in at vexa.ai/signin with a Google account and copy your key from your account page — free credit, no card required. Self-hosted: make all prints a key when the stack comes up.

What is proven, and what is not

  • Live delivery — proven. Owner-witnessed production walk, 2026-07-19: POSTs arrived at an external webhook.site inbox from production IPs, one per walk event, matched one-for-one by meeting-api’s delivery-outcome log at code=200.
  • Delivery History — broken. The user-facing history reads a store real deliveries were never written to, so it lists none of them (#841, open). Do not read an empty history as “nothing was delivered.”
  • CI does not exercise the network leg. The module and eval tests drive the real WebhookSink against a fake in-memory receiver, so a regression in the transport itself would not be caught by tests — only by another live walk.
  • No settings UI — configuration is API-only (PUT /user/webhook).
  • Exactly-once emission (#519) and outage-durable retry (#520) both closed as completed in July.
The live status row for this feature is maintained on Roadmap → Status.