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:
- Mint — through the route for the thing you need (see Where hand-offs
come from below). You get back a
handoff_id. - Hand over the URL —
GET /api/v1/handoffs/{id}returns it while the hand-off is pending. Send it however you already talk to your user. - Poll — the same endpoint. When
statusstops beingpending, 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
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 -H "Authorization: Bearer atb_live_YOUR_KEY" \
"https://api.atribu.app/api/v1/handoffs/0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21"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.");
}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
{
"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
kind | what the human does | minted by |
|---|---|---|
connect | grants a provider OAuth consent (Meta, Google Ads, Search Console, GoHighLevel, Stripe, MercadoPago, Shopify) | the connect hand-off route |
pick | chooses among the assets a consent exposed — today, the Conversion Sync setup link's Meta ad account, pixel and dataset | the Conversion Sync setup-link mint |
sign_dpa | accepts a Data Processing Agreement or the combined DPA & BAA | POST /api/v1/legal/{document}/handoff |
checkout | pays for a plan. Workspace-level: it names no profile | POST /api/v1/workspaces/{id}/checkout-session |
approve | approves a consequential write your agent proposed | the 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
status | meaning |
|---|---|
pending | still open. url is present and works. |
completed | the human finished. result carries what came of it. |
expired | expires_at passed with nothing done. Mint a fresh hand-off. |
cancelled | withdrawn or superseded — typically because a newer hand-off replaced it. |
failed | attempted 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.