Connections
Read what a profile is connected to — and start a connect your agent cannot grant itself, by handing a URL to a human.
A connection is a profile's live link to an ad platform, a CRM, a payment provider or a store: the thing that makes campaigns, conversions and revenue appear in every other endpoint. Reading them is a normal API call. Creating one is not — the provider's consent screen needs a person in a browser, which is what this page is mostly about.
Read what a profile is connected to
GET /api/v1/connections
GET /api/v1/connections/{id}
DELETE /api/v1/connections/{id}GET /api/v1/connections is the durable answer to "is this connected, and is it
healthy". Each row carries channel (the provider), status, and a sync
object with the last successful run and the provider's own error text when the
last one failed. There is no separate health endpoint — poll this, narrowed with
?channel= when only one matters.
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
"https://api.atribu.app/api/v1/connections?channel=meta_ads"Find the pixel a CAPI destination needs
GET /api/v1/connections/{id}/pixelsScope: exports:read — part of the attribution grant, the same one that
carries exports:write for creating the destination itself.
POST /api/v1/exports/destinations requires a meta_pixel_id, and until this
endpoint there was no way for a key to learn one: the dataset read only
reconciles a dataset you have already configured, and the setup-link mint is
for a signed-in human and refuses API keys. So a headless connect ended by
asking the account owner to copy an id out of Events Manager.
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
"https://api.atribu.app/api/v1/connections/$CONNECTION_ID/pixels"{
"data": {
"pixels": [
{
"id": "1234567890123456",
"name": "Dealer Santiago — Web",
"created_at": "2025-04-02T11:20:15-0700",
"last_fired_at": "2026-09-14T08:41:02-0700",
"is_unavailable": false,
"owner_business": { "id": "998877665544332", "name": "Grupo Dealer" }
}
]
},
"meta": { "profile_id": "3f1c9d2e-7a64-4a6f-9d58-0b3a2c1e4d55" }
}id is the only field Meta always returns. Everywhere else, null means Meta
did not say — not false and not zero. A null last_fired_at is usually a
pixel that has never fired, which is what tells you which of several to pick; a
null is_unavailable is a pixel Meta gave no verdict on, so do not render it
as healthy.
The id your caller must take is the connection's, not the ad account's: a
connection that is not meta_ads answers 409, and one on another profile
answers the same 404 an unknown id does. One live Meta call per request, never
cached — you are about to write a destination against the answer.
Start a connect and hand it to a human
POST /api/v1/connections/{provider}/handoffScope: connections:write — every write on this rail takes it
(pending/finalize, disconnect, sync); the one read,
GET …/sync/status, takes connections:read instead. A Partner App gets
both through the connectionless connections OAuth scope; see
Connect broker vs. the attribution Add-on
for how that scope is minted, and widened into the full attribution Add-on.
Since #1455: a key minted under an older attribution_write grant is
still accepted here during the swap window, until the consumer re-mints
against connections.
Your agent cannot grant an OAuth consent. Meta, Google and GoHighLevel put a permissions screen in front of a logged-in person, and no API key gets past it. So instead of failing, mint a hand-off: you get a URL, you give it to your user, and you poll until they are done.
import { AtribuClient } from "@atribu/node";
const client = new AtribuClient({ apiKey: process.env.ATRIBU_API_KEY });
// 1. Mint. No body needed — the provider and your key's profile are enough.
const { data: handoff } = await client.connections.handoff("meta_ads");
// 2. Hand the URL over, however you already talk to your user.
console.log("Ask them to open:", handoff.url);
// 3. Poll.
const { data: state } = await client.handoffs.get(handoff.id);
if (state.status === "completed") {
console.log("connected:", state.result?.connection_id);
}curl -X POST \
-H "Authorization: Bearer atb_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{}' \
"https://api.atribu.app/api/v1/connections/meta_ads/handoff"The response is a hand-off — the same object
GET /api/v1/handoffs/{id} returns, so your mint response and your poll
response are one shape and you need one parser.
{
"data": {
"id": "0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21",
"kind": "connect",
"status": "pending",
"url": "https://www.atribu.app/h/9tQ2mB1xQhK4vT7nJ0pL9sD3fG6hZ5cW2aY1bN4uM8E",
"start_url": "https://www.atribu.app/api/integrations/meta/oauth/start?handoff_handle=9tQ2mB1xQhK4vT7nJ0pL9sD3fG6hZ5cW2aY1bN4uM8E",
"expires_at": "2026-09-05T11:30:00.000Z",
"created_at": "2026-09-05T10:45:00.000Z",
"completed_at": null,
"result": null
},
"meta": { "profile_id": "8f3d…" }
}Which providers
meta_ads · google_ads · google_search_console · gohighlevel · stripe ·
mercadopago
These are the connection_provider values GET /api/v1/connections reports as
channel, so a connection you read back can be fed straight into a re-connect
with no translation. Anything else is a 400.
shopify is not on the list, and cannot be. A Shopify install begins inside
Shopify — the merchant opens your app's App Store listing and Shopify calls
Atribu with an HMAC-signed request. There is no consent Atribu can start on the
merchant's behalf, so a hand-off URL for it would have nowhere to go. Send the
merchant to the listing instead.
Two links: start_url and url
start_url goes straight to the provider's consent screen. No page of ours
renders in between. This is the one a partner app sends its user to: from your
own "Connect" button, to the provider's screen, and (with a return_url) back
to your page. Your user never sees a page of ours on that path.
url is a one-screen page for a human with no app behind them: which
provider, which client, and a Continue button. It shows your app's name and
logo (oauth_apps.name / logo_url) when your app governs the profile,
never ours. It is session-less: whoever holds the link completes it,
signed out, on a phone, with no account with us. The person who can grant a
Meta consent is usually the business owner, not the operator running your
agent.
Both links are the same capability, with the same lifetime. The registered redirect URI with each provider is unchanged, so nothing about the provider-side app configuration depends on this flow.
Two ways it completes
Both answer status: "completed", because in both the part only a browser could
do is finished.
result | what happened | what you do next |
|---|---|---|
{"provider": "meta_ads", "connection_id": "…"} | the consent resolved to exactly one account, and it is connected | read it back with GET /api/v1/connections/{id} |
{"provider": "meta_ads", "pending_selection": {…}} | the consent exposed several accounts and a human must choose | GET /api/v1/connections/pending/{provider}, then POST …/finalize |
pending_selection carries candidate_count and expires_at. That expires_at
is a different, shorter clock than the hand-off's own — the provider token is
parked for 60 minutes, and once it lapses the consent is gone and the connect
must be started again.
When it fails or is cancelled
status: "cancelled" when your user cancelled on the provider's screen;
status: "failed" otherwise. result.reason says why, and it is the same code
the bounce carries:
reason | status | meaning | what your UI does |
|---|---|---|---|
user_cancelled | cancelled | your user cancelled or declined on the provider's consent screen | offer to try again |
provider_denied | failed | the provider refused the request itself (an app not enabled for this account, a scope it will not grant) | not retryable as-is; the account owner must allow the app at the provider |
provider_error | failed | the provider failed (an error page, no authorization code) | retry later |
token_exchange_failed | failed | the provider refused to exchange the authorization code | retry; if it repeats, contact support |
account_connected_elsewhere | failed | the account is already connected to another profile of this workspace (all six providers; the same account in another workspace is allowed) | disconnect it there first |
no_candidates | failed | consent granted, and the account has nothing to connect | not retryable: they need access at the provider first |
connect_failed | failed | consent granted, but storing the connection failed on our side | retry |
internal_error | failed | a transient failure on our side before anything was written. The hand-off is not settled: the same start_url can be retried | retry the same link |
state_expired | failed | the consent screen was open for more than 15 minutes | retry |
state_invalid | failed | the flow could not be verified: it was finished in a different browser, or tampered with | retry in one browser |
handoff_expired | failed | the hand-off lapsed (45 minutes) | mint a new one |
handoff_used | failed | the hand-off had already ended as failed or cancelled, or another consent for it was still finishing. A second click or tab on a completed hand-off normally reports the real outcome (success / pending_selection) instead; confirm with the poll | poll; mint a new one if it did not complete |
provider_not_configured | failed | the provider is not configured on our side | contact support |
origin_not_allowed | failed | your app's registration changed after the mint, and the return_url is no longer allowed. Poll only: there is no registered page to send the user to | fix the registration |
Nothing is written on any of these paths. One exception at the provider:
GoHighLevel installs the app on the location as part of its consent, so an
account_connected_elsewhere refusal leaves that install in place on
GoHighLevel's side (nothing is stored on ours). Uninstall it from the location
if it is not wanted.
One consent per hand-off. If your user opens start_url twice (a
double-click, a forwarded link) and finishes both consents, exactly one of
them is used: the other writes nothing and normally reports the outcome of
the one that won. The bounce is a hint; confirm with the poll. If a consent
stops partway on our side, the link can be used again after 3 minutes. The
state cookie and the signed state both last 15 minutes, so a consent that runs
longer ends state_expired.
The URL is single-use and short-lived
45 minutes, and the moment the hand-off settles: success, failure, or cancel.
Both ends enforce it: an expired link cannot start a consent, and a consent that
finishes after the hand-off lapsed writes nothing rather than connecting
late. A spent start_url returns your user to your return_url with
handoff_used or handoff_expired; a spent url shows which one happened.
Minting twice is two hand-offs, not an error. To retry after a failure or a cancel, mint a new one.
Optional: profile_id and return_url
{
"profile_id": "8f3d…",
"return_url": "https://app.example.com/onboarding/atribu"
}profile_id is an assertion, not a selector. Your credential already names
a profile; sending a different one is a 404. Send it when you want the request
to fail loudly if your key is scoped somewhere you did not expect.
return_url sends your user to your own page when the consent ends (success,
failure, or cancel) instead of to the hand-off page. Its origin must be one
your app can receive a bounce on: any origin from your app's own
redirect_uris works automatically, and allowed_return_origins narrows that
set or adds an origin your redirect_uris do not cover. This is checked at
the mint: an origin outside both sets, a profile no app governs, or (for a
delegated key) a profile governed by a different app than the key's is a
400 invalid_parameter, and nothing is created. So is a return_url that
carries a username or password (https://user@host/…).
The bounce carries:
?connect=<slug>&provider=<provider>&status=<status>&profile_id=<id>&handoff_id=<id>
[&reason=<reason>] on failed / cancelled
[&candidate_count=<n>] on pending_selection| param | value |
|---|---|
status | success (connected), pending_selection (a human choice is owed; see below), failed, cancelled |
reason | one of the codes above |
provider | the provider you minted with (meta_ads, gohighlevel, …) |
connect | the older consumer slug (meta, gohighlevel, …), kept for existing readers |
handoff_id | the hand-off's id, so you can match the bounce to what you minted |
candidate_count | a count only, never account ids or names |
Your own query parameters on return_url are kept. Treat the bounce as a
signal and read the outcome from GET /api/v1/handoffs/{id}: a query string
can be edited by the person holding it.
Finish a multi-account connect
GET /api/v1/connections/pending/{provider}
POST /api/v1/connections/pending/{provider}/finalizeScope: connections:write — the same connect-rail scope
handoff takes, including the
same attribution_write swap-window exception.
You only reach these when result.pending_selection says a human choice is
owed. GET lists the accounts, properties or locations on offer — one flat
shape across providers, with id and name always present. POST …/finalize
commits one of them by candidate_id, verbatim.
Retries are safe: repeating the same candidate_id answers 200 with
already_finalized: true. A different one after the choice is made is a
409 — switching accounts is a new connect, not a retry.
A candidate_id that is already connected to another profile of this
workspace is 409 account_connected_elsewhere, and nothing is written: the
parked token stays, so your user can pick a different account.
Revoke
DELETE /api/v1/connections/{id} revokes your app's authorization for the
connection and every API key that authorization minted — including, usually, the
key you are calling with. It does not disconnect the underlying data
connection; other consumers and the Atribu console still see it.
Every route under /connections
| Method | Path | What it does |
|---|---|---|
GET | /api/v1/connections | List authorized data connections |
GET | /api/v1/connections/{id} | Get a single connection |
DELETE | /api/v1/connections/{id} | Revoke this OAuth app's authorization for a connection |
POST | /api/v1/connections/{id}/disconnect | Disconnect an integration |
GET | /api/v1/connections/{id}/pixels | List the Meta pixels on an ad-account connection |
POST | /api/v1/connections/{provider}/handoff | Hand a provider connect to a human and get a URL for them |
GET | /api/v1/connections/{provider}/stages | Read a CRM's pipeline stages with the suggested and current outcome mapping |
GET | /api/v1/connections/pending/{provider} | List the accounts a pending connect can be finalized against |
POST | /api/v1/connections/pending/{provider}/finalize | Finalize a pending connect with the chosen account |
POST | /api/v1/connections/sync | Start a sync |
GET | /api/v1/connections/sync/status | Read a provider's sync progress |
POST | /api/v1/integrations/fintoc/link/connect | Finish a Fintoc bank link and persist the connection |
POST | /api/v1/integrations/fintoc/link/start | Start a Fintoc bank link |
GET | /api/v1/integrations/lhg/outcomes/prefill | Suggest outcome-event mappings from GoHighLevel's pipeline stages |
GET | /api/v1/integrations/manychat/connection | Read the ManyChat connection for this profile |
POST | /api/v1/integrations/manychat/connection | Create or update the ManyChat connection for this profile |
GET | /api/v1/integrations/meta/pages | List the tracked Meta Pages for this profile |
POST | /api/v1/integrations/meta/pages | Select which Meta Pages to track |
GET | /api/v1/integrations/shopify/manual-connections | List a profile's manual Shopify webhook connections |
POST | /api/v1/integrations/shopify/manual-connections | Create a manual Shopify webhook connection |
PATCH | /api/v1/integrations/shopify/manual-connections/{connectionId} | Store the store's webhook signing ID |
DELETE | /api/v1/integrations/shopify/manual-connections/{connectionId} | Remove a manual Shopify webhook connection |
POST | /api/v1/integrations/shopify/web-pixel/activate | Re-run the Shopify app pixel installation |
Generated from openapi.json. The full request and response schema for every operation is in the OpenAPI document.