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 |
leadgen.received | a Meta Instant Form was submitted on a Page the profile connected |
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.
Instant Form leads: leadgen.received
When a person submits a Meta Instant Form on a Page the profile connected,
Atribu reads the lead from the Graph API and delivers it as one
leadgen.received event (provider atribu, so opt-in). data is the Graph
lead merged over Meta's webhook value, plus profile_id; id is the
leadgen_id, so de-dupe on it. Meta prefixes phone answers with p:.
{
"id": "444444444444001",
"type": "leadgen.received",
"occurred_at": "2026-10-11T15:00:00.000Z",
"provider": "atribu",
"data": {
"profile_id": "<profile id>",
"page_id": "109876543210987",
"leadgen_id": "444444444444001",
"form_id": "1234567890123456",
"ad_id": "120211000000000401",
"campaign_id": "120211000000000201",
"field_data": [{ "name": "phone_number", "values": ["p:+56912345678"] }]
}
}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>",
"outcome_event_id": "<Atribu's id for the outcome>",
"conversion_id": "<the conversion it produced, or null>",
"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.outcome_event_id/conversion_idare the idsGET /api/v1/campaigns/{id}/conversionsreturns for the same outcome, so an event joins to the campaign credited for it.conversion_idisnullwhen the outcome produced no conversion, or none yet: conversions are extracted asynchronously, so an early delivery can carrynullwhere the replay later carries the id.outcome_event_idalways joins.- Healthcare profiles reach only the app that provisioned them: for a
healthcare workspace, or a profile in Platform-Safe or HIPAA privacy mode,
the event goes to the app that provisioned the profile and to no other. That
app gets the person when its grant on the profile opens contact (a grant
holding
customers:read,analytics_piiorattribution, the webhook's usual rule), on the webhook and on the replay alike, although its key carries nocustomers:readfor such a profile. The replay answers that app's key only; any other, whatever its scopes, is403 healthcare_scope_forbidden. - 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.