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"

Start a connect and hand it to a human

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

Scope: attribution:write

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",
    "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.

The page your user opens

url lands on a one-screen page: which provider, which client, and a Continue button. It is session-less — whoever holds the link completes it, signed out, on a phone, without an Atribu account. That is the point: the person who can grant a Meta consent is usually the business owner, not the operator running your agent.

Continue takes them to the provider's own consent screen. Atribu's registered redirect URI 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

status: "failed", and result.reason says why:

reasonmeaning
provider_deniedyour user declined on the provider's screen
no_candidatesconsent granted, and the account has nothing to connect. Not retryable — they need to be granted access at the provider first
connect_failedthe exchange or the write failed after consent
token_exchange_failedthe provider refused the authorization code
origin_not_allowedthe return_url you supplied is not registered for this profile's app (see below)
handoff_unusablethe hand-off expired or was already used while your user was on the consent screen. Nothing was written; mint a fresh one

The URL is single-use and short-lived

45 minutes, and the moment the hand-off settles. 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. Your user sees a page telling them which happened, not a dead link.

Minting twice is two hand-offs, not an error — it is the honest reading of "they never opened the first one, send another".

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 finishes, instead of back to the hand-off page. It receives ?connect=<slug>&status=…&profile_id=…, the same query Atribu's consumer connect bounce has always used. Its origin must be registered in your OAuth app's allowed_return_origins for this profile — and that check runs when your user clicks, not at the mint, so an unregistered origin surfaces on your poll as status: "failed", result.reason: "origin_not_allowed". Register the origin before you send the link.

Finish a multi-account connect

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

Scope: attribution:write

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.

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/{provider}/handoffHand a provider connect to a human and get a URL for them
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

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

Next steps

On this page