Atribu
API Reference

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

Endpoints
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
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

Endpoint
GET /api/v1/connections/{id}/pixels

Scope: 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
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/connections/$CONNECTION_ID/pixels"
Response
{
  "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

Endpoint
POST /api/v1/connections/{provider}/handoff

Scope: 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.

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

Success response (200 OK)
{
  "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.

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.

resultwhat happenedwhat you do next
{"provider": "meta_ads", "connection_id": "…"}the consent resolved to exactly one account, and it is connectedread it back with GET /api/v1/connections/{id}
{"provider": "meta_ads", "pending_selection": {…}}the consent exposed several accounts and a human must chooseGET /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:

reasonstatusmeaningwhat your UI does
user_cancelledcancelledyour user cancelled or declined on the provider's consent screenoffer to try again
provider_deniedfailedthe 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_errorfailedthe provider failed (an error page, no authorization code)retry later
token_exchange_failedfailedthe provider refused to exchange the authorization coderetry; if it repeats, contact support
account_connected_elsewherefailedthe 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_candidatesfailedconsent granted, and the account has nothing to connectnot retryable: they need access at the provider first
connect_failedfailedconsent granted, but storing the connection failed on our sideretry
internal_errorfaileda transient failure on our side before anything was written. The hand-off is not settled: the same start_url can be retriedretry the same link
state_expiredfailedthe consent screen was open for more than 15 minutesretry
state_invalidfailedthe flow could not be verified: it was finished in a different browser, or tampered withretry in one browser
handoff_expiredfailedthe hand-off lapsed (45 minutes)mint a new one
handoff_usedfailedthe 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 pollpoll; mint a new one if it did not complete
provider_not_configuredfailedthe provider is not configured on our sidecontact support
origin_not_allowedfailedyour 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 tofix 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

Request body (both optional)
{
  "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:

Query appended to your return_url
?connect=<slug>&provider=<provider>&status=<status>&profile_id=<id>&handoff_id=<id>
 [&reason=<reason>]           on failed / cancelled
 [&candidate_count=<n>]       on pending_selection
paramvalue
statussuccess (connected), pending_selection (a human choice is owed; see below), failed, cancelled
reasonone of the codes above
providerthe provider you minted with (meta_ads, gohighlevel, …)
connectthe older consumer slug (meta, gohighlevel, …), kept for existing readers
handoff_idthe hand-off's id, so you can match the bounce to what you minted
candidate_counta 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

Endpoints
GET  /api/v1/connections/pending/{provider}
POST /api/v1/connections/pending/{provider}/finalize

Scope: 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

MethodPathWhat it does
GET/api/v1/connectionsList 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}/disconnectDisconnect an integration
GET/api/v1/connections/{id}/pixelsList the Meta pixels on an ad-account connection
POST/api/v1/connections/{provider}/handoffHand a provider connect to a human and get a URL for them
GET/api/v1/connections/{provider}/stagesRead 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}/finalizeFinalize a pending connect with the chosen account
POST/api/v1/connections/syncStart a sync
GET/api/v1/connections/sync/statusRead a provider's sync progress
POST/api/v1/integrations/fintoc/link/connectFinish a Fintoc bank link and persist the connection
POST/api/v1/integrations/fintoc/link/startStart a Fintoc bank link
GET/api/v1/integrations/lhg/outcomes/prefillSuggest outcome-event mappings from GoHighLevel's pipeline stages
GET/api/v1/integrations/manychat/connectionRead the ManyChat connection for this profile
POST/api/v1/integrations/manychat/connectionCreate or update the ManyChat connection for this profile
GET/api/v1/integrations/meta/pagesList the tracked Meta Pages for this profile
POST/api/v1/integrations/meta/pagesSelect which Meta Pages to track
GET/api/v1/integrations/shopify/manual-connectionsList a profile's manual Shopify webhook connections
POST/api/v1/integrations/shopify/manual-connectionsCreate 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/activateRe-run the Shopify app pixel installation

Generated from openapi.json. The full request and response schema for every operation is in the OpenAPI document.

Next steps

On this page