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"Start a connect and hand it to a human
POST /api/v1/connections/{provider}/handoffScope: 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.
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",
"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.
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
status: "failed", and result.reason says why:
reason | meaning |
|---|---|
provider_denied | your user declined on the provider's screen |
no_candidates | consent granted, and the account has nothing to connect. Not retryable — they need to be granted access at the provider first |
connect_failed | the exchange or the write failed after consent |
token_exchange_failed | the provider refused the authorization code |
origin_not_allowed | the return_url you supplied is not registered for this profile's app (see below) |
handoff_unusable | the 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
{
"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
GET /api/v1/connections/pending/{provider}
POST /api/v1/connections/pending/{provider}/finalizeScope: 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
| 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/{provider}/handoff | Hand 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}/finalize | Finalize 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.