Webhooks
Subscribe to what Atribu finishes, instead of polling it — including the platform lifecycle events an agent waits on.
Atribu fans out events to HTTPS endpoints you register. Before #1049 the whole platform was poll-only: an agent that started an OAuth connect, queued a recompute or fired an export had no way to learn it landed except by asking again.
Subscribing
GET /api/v1/webhooks/subscriptions
POST /api/v1/webhooks/subscriptions
PATCH /api/v1/webhooks/subscriptions/{id}
DELETE /api/v1/webhooks/subscriptions/{id}A subscription names a url, a list of events, and a list of providers. A
delivery is matched on both lists — an event whose provider your
subscription does not name is not delivered, whatever its event type.
Platform lifecycle events are opt-in, and the trap is the provider list
Every lifecycle event below carries the provider atribu, not a channel — a
recompute is not WhatsApp, and a connection.connected for meta_ads is not
any messaging channel either. So an existing subscription whose providers is
["whatsapp"] receives none of them until it PATCHes atribu in. The
connector's real provider rides in data.provider.
The platform lifecycle events
| event | fires when |
|---|---|
connection.connected | a provider connect completed |
connection.reconnect_required | a connection stopped delivering and needs re-authorizing |
connection.revoked | a connection was removed |
handoff.completed | a hand-off you minted was finished by a human |
recompute.completed | an attribution recompute finished |
conversion.attributed | once per conversion definition, on its first attributed conversion |
export.completed / export.failed | a Conversion Sync export run settled |
profile.freshness.changed | the profile's attribution freshness moved |
outcome.recorded | a GoHighLevel or MercadoPago outcome was recorded on a profile your app provisioned |
conversion.attributed firing exactly once, durably, is what makes it usable as
"the setup worked" — it is stamped on the definition row by the projector, never
recomputed by a scan.
Provider outcomes: outcome.recorded
Each GoHighLevel outcome (a lead, a booking, a won or lost deal — whatever the
stage mapping says) and each MercadoPago
payment recorded on a profile your app provisioned arrives as one
outcome.recorded event:
{
"id": "00000000-0000-4000-8000-00000000000a",
"type": "outcome.recorded",
"occurred_at": "2026-09-29T10:00:00.000Z",
"app_id": "<your app id>",
"provider": "atribu",
"connection_id": null,
"data": {
"profile_id": "<profile id>",
"provider": "gohighlevel",
"outcome_type": "lead_created",
"occurred_at": "2026-09-29T10:00:00.000Z",
"recorded_at": "2026-09-29T10:00:04.000Z",
"external_id": "<provider's id for the outcome>",
"customer_key": "<provider's customer id>",
"value": null,
"contact": { "first_name": "…", "last_name": "…", "email": "[email protected]", "phone": "+15555550100" },
"contact_withheld": false
}
}idis stable per outcome. Retries, a re-sync or an attribution recompute never produce a second event for the same outcome. De-dupe on it.- Delivery needs a live grant for your app on the profile.
contactis included only when that grant carriescustomers:read. Without it,contactisnull,contact_withheldistrue, and acustomer_keythat is not the provider's own opaque id (an email, any phone notation) isnull. The person is read at each delivery attempt, so a retry after the customer was erased carries no person.- Healthcare profiles never produce it: a healthcare workspace, or a profile in Platform-Safe or HIPAA privacy mode.
- A first sync records historical outcomes too: filter on
data.occurred_atto skip them.
Order and replay. Order is guaranteed only for the replay:
GET /api/v1/outcomes/recorded (conversions:read or customers:read) returns
the same events with the same ids, in one stable order, from an opaque cursor.
The webhook itself is at-least-once and can arrive out of order: drains can
overlap, a failed row is retried later, and each delivery has its own retry
schedule. De-dupe on id, and reconcile through the replay. Store pagination.cursor (it is present on the last page too) and pass
it back later; has_next: false means you are caught up. Use it to backfill a
new subscriber or to reconcile after downtime. Events stay replayable for 90
days after delivery; an erased customer's events are not replayed.
The messaging and commerce event types (message.received,
order.status.changed, catalog.updated, …) are documented with the surfaces
that produce them — see the Node SDK reference.
Secrets, tests and replays
POST /api/v1/webhooks/subscriptions/{id}/rotate-secret
POST /api/v1/webhooks/test/{id}
POST /api/v1/webhooks/deliveries/{id}/replayrotate-secret is an atomic dual-key rotation with a grace window: the old
secret keeps verifying while you deploy the new one, so a rotation is not an
outage. test/{id} fires a synthetic, correctly-signed event — the right way to
prove your verifier works before real traffic depends on it. replay re-enqueues
an existing delivery payload rather than fabricating a new one, so a consumer
that was down gets the actual bytes it missed.
Every delivery is HMAC-signed; verify the signature before trusting the body.
The runbook for adding a consumer is
docs/external-webhooks.md.
| Method | Path | What it does |
|---|---|---|
GET | /api/v1/outcomes/recorded | Replay outcome.recorded events |
POST | /api/v1/webhooks/deliveries/{id}/replay | Replay a webhook delivery |
GET | /api/v1/webhooks/subscriptions | List webhook subscriptions |
POST | /api/v1/webhooks/subscriptions | Create a webhook subscription |
PATCH | /api/v1/webhooks/subscriptions/{id} | Update a webhook subscription |
DELETE | /api/v1/webhooks/subscriptions/{id} | Delete a webhook subscription |
POST | /api/v1/webhooks/subscriptions/{id}/rotate-secret | Rotate a subscription's HMAC secret |
POST | /api/v1/webhooks/test/{id} | Fire a synthetic test event |
Generated from openapi.json. The full request and response schema for every operation is in the OpenAPI document.