Atribu
API Reference

Hand-offs

Give the user this URL, poll this handle: how an agent gets through the steps only a human can take.

Most of Atribu is completable by a program. A few steps are not, by design: granting an OAuth consent, choosing which Meta ad account to connect, signing a Data Processing Addendum, paying for a plan, approving a write with real consequences. A person has to do those in a browser.

A hand-off is how your agent gets past them without a browser of its own. You mint one, hand the URL to your user, and poll until it settles:

  1. Mint — through the route for the thing you need (see Where hand-offs come from below). You get back a handoff_id.
  2. Hand over the URLGET /api/v1/handoffs/{id} returns it while the hand-off is pending. Send it however you already talk to your user.
  3. Poll — the same endpoint. When status stops being pending, continue.

It is the pattern agent hosts already use for a Gmail or Cloudflare consent, and it is deliberately the same object for all five kinds: one shape to poll, one expiry to reason about, one place to look when something goes quiet.

Poll a hand-off

Endpoint
GET /api/v1/handoffs/{id}

Scope: analytics:read

The weakest read scope on this surface, on purpose. The mint is what needs authority — starting a connect needs attribution:write, and a checkout needs a billing scope. Asking a question about a hand-off your workspace already minted needs neither, so a long-lived read key can watch what a write key started.

Request

cURL
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/handoffs/0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21"
JavaScript
import { AtribuClient } from "@atribu/node";

const client = new AtribuClient({ apiKey: process.env.ATRIBU_API_KEY });
const { data } = await client.handoffs.get(handoffId);

if (data.status === "pending") {
  console.log("Ask your user to open:", data.url);
} else if (data.status === "completed") {
  console.log("Done — carry on.");
}
Python
import requests

res = requests.get(
    f"https://api.atribu.app/api/v1/handoffs/{handoff_id}",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
)
data = res.json()["data"]

Response

Success response (200 OK)
{
  "data": {
    "id": "0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21",
    "kind": "pick",
    "status": "pending",
    "url": "https://www.atribu.app/h/9tQ2mB1xQhK4vT7nJ0pL9sD3fG6hZ5cW2aY1bN4uM8E",
    "expires_at": "2026-09-19T10:45:00.000Z",
    "created_at": "2026-09-05T10:45:00.000Z",
    "completed_at": null,
    "result": null
  },
  "meta": { "profile_id": "8f3d…" }
}

The three things worth knowing

An expired hand-off is a 200, never a 404

status becomes expired the moment expires_at passes. There is no sweeper to fall behind it, so there is no window in which a poll answers pending for a link that no longer works — and, more importantly, no ambiguity between "that lapsed, mint another" and "you asked for the wrong id". A 404 here means the id names nothing your workspace minted: never created, malformed, or someone else's. Those three are deliberately indistinguishable.

url is returned only while status is pending

For connect, sign_dpa and checkout, whoever holds the URL can complete the hand-off. That is the design — your user does not need an Atribu account, a session, or a desktop; a link opened on a phone, signed out, is the ordinary case. It is also why a settled hand-off stops returning the URL: there is nothing left to do with it, and handing back a dead link only invites a client to keep showing one.

`approve` is the exception

An approve hand-off needs a signed-in workspace owner or admin, and it must not be the person whose credential requested it. The other three kinds ask a human to finish an external step — grant a consent, accept a DPA, pay — and holding the link is the only claim they make. approve asks for authority: it is minted by the very principal whose write was refused, so if holding the link were enough, an agent could approve itself.

Opening the URL signed out shows the change and a Sign in to approve button (returning to the same link); the decision endpoint refuses with 401 when there is no session, 403 when the signed-in user is not an owner/admin of that workspace, and 403 when they are the requester. A refusal leaves the hand-off open, so the right person can still decide it. result.completed_by records who did.

Capture url when you first read it and give it to your user. Re-poll for status, not for the URL.

There is no POST /api/v1/handoffs

A hand-off with nothing behind it is a URL nobody can complete. Each kind is minted by the route for the thing it hands off, which is the only place that knows what finishing means. This endpoint is the one poll they all share.

The five kinds

kindwhat the human doesminted by
connectgrants a provider OAuth consent (Meta, Google Ads, Search Console, GoHighLevel, Stripe, MercadoPago, Shopify)the connect hand-off route
pickchooses among the assets a consent exposed — today, the Conversion Sync setup link's Meta ad account, pixel and datasetthe Conversion Sync setup-link mint
sign_dpaaccepts a Data Processing Agreement or the combined DPA & BAAPOST /api/v1/legal/{document}/handoff
checkoutpays for a plan. Workspace-level: it names no profilePOST /api/v1/workspaces/{id}/checkout-session
approveapproves a consequential write your agent proposedthe approval route

pick, sign_dpa and checkout are live today. connect and approve land as their mint routes ship; a hand-off of a kind whose flow is not wired yet renders a page that says so plainly rather than a dead link.

The three live kinds send the human to three different places, and the landing page resolves which: pick to the Conversion Sync setup page, sign_dpa to the click-wrap at /dpa/sign/<handle>, and checkout straight out to Stripe. None of them redirects once the hand-off has settled — a spent Stripe session renders an error page, and a completed signature has nothing left to sign.

Statuses

statusmeaning
pendingstill open. url is present and works.
completedthe human finished. result carries what came of it.
expiredexpires_at passed with nothing done. Mint a fresh hand-off.
cancelledwithdrawn or superseded — typically because a newer hand-off replaced it.
failedattempted and could not be finished. result says why.

result also carries completed_by: the Atribu user id when a signed-in person finished the hand-off, and null when an anonymous link holder did. The null is recorded rather than omitted — "nobody was signed in" is a fact about how the hand-off completed, not a missing field.

Expiry

45 minutes by default — the same budget Atribu's own consumer connect detour ships against, arrived at because five minutes did not survive a real human working through Meta's Embedded Signup.

A kind whose underlying object has its own clock mirrors that clock instead. A pick hand-off for a Conversion Sync setup link lives as long as the link does (14 days, extended when the agency asks the client to fix a partial grant), so you are never told a hand-off expired while your user is looking at a live page. Always read expires_at; never assume the default.

On this page