Atribu
API Reference

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

Endpoints
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

eventfires when
connection.connecteda provider connect completed
connection.reconnect_requireda connection stopped delivering and needs re-authorizing
connection.revokeda connection was removed
handoff.completeda hand-off you minted was finished by a human
recompute.completedan attribution recompute finished
conversion.attributedonce per conversion definition, on its first attributed conversion
export.completed / export.faileda Conversion Sync export run settled
profile.freshness.changedthe profile's attribution freshness moved
outcome.recordeda GoHighLevel or MercadoPago outcome was recorded on a profile your app provisioned
leadgen.receiveda 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:.

leadgen.received
{
  "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:

outcome.recorded
{
  "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
  }
}
  • id is 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.
  • contact is included only when that grant carries customers:read. Without it, contact is null, contact_withheld is true, and a customer_key that is not the provider's own opaque id (an email, any phone notation) is null. The person is read at each delivery attempt, so a retry after the customer was erased carries no person.
  • outcome_event_id / conversion_id are the ids GET /api/v1/campaigns/{id}/conversions returns for the same outcome, so an event joins to the campaign credited for it. conversion_id is null when the outcome produced no conversion, or none yet: conversions are extracted asynchronously, so an early delivery can carry null where the replay later carries the id. outcome_event_id always 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_pii or attribution, the webhook's usual rule), on the webhook and on the replay alike, although its key carries no customers:read for such a profile. The replay answers that app's key only; any other, whatever its scopes, is 403 healthcare_scope_forbidden.
  • A first sync records historical outcomes too: filter on data.occurred_at to 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

Endpoints
POST /api/v1/webhooks/subscriptions/{id}/rotate-secret
POST /api/v1/webhooks/test/{id}
POST /api/v1/webhooks/deliveries/{id}/replay

rotate-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.

MethodPathWhat it does
GET/api/v1/outcomes/recordedReplay outcome.recorded events
POST/api/v1/webhooks/deliveries/{id}/replayReplay a webhook delivery
GET/api/v1/webhooks/subscriptionsList webhook subscriptions
POST/api/v1/webhooks/subscriptionsCreate 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-secretRotate 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.

Next steps

On this page