Atribu
API Reference

Legal (DPA / BAA)

Your agent cannot sign a contract. Mint a signing hand-off, give the URL to a person, and poll.

Before Atribu forwards a customer's conversion to an ad platform, the workspace has to have accepted a Data Processing Agreement. Healthcare Mode additionally needs a HIPAA Business Associate Agreement, which ships as one combined DPA & BAA click-wrap.

Neither is something a program can do. An API key is a machine credential — and a delegated key is an agency's app acting inside someone else's workspace — so an acceptance either of them could have produced would record nothing worth recording. An MCP user token is refused too, and deliberately: it names a real person, but it is the credential they minted for an agent, so accepting with one is an agent signing on their behalf. Only a browser session says a person is present. POST /api/v1/legal/{document}/accept refuses all three with a 403 that names the alternative.

The alternative is a hand-off: a session-less URL you give to a person, an id you poll, and an expiry.

Mint a signing hand-off

Endpoint
POST /api/v1/legal/dpa/handoff
POST /api/v1/legal/baa/handoff

Scope: exports:write · Body: none

cURL
curl -X POST -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://api.atribu.app/api/v1/legal/dpa/handoff"
JavaScript
import { AtribuClient } from "@atribu/node";

const client = new AtribuClient({ apiKey: process.env.ATRIBU_API_KEY });
const handoff = await client.legal.signDpa();

if (handoff.status === "pending") {
  // Send this to whoever can agree on the customer's behalf.
  console.log("Please sign here:", handoff.url);
} else {
  console.log("Already signed — nothing to do.");
}
Success response (200 OK)
{
  "data": {
    "id": "0f2f6b0a-9a2c-4f4e-9a1e-6f7f2a5c3b21",
    "kind": "sign_dpa",
    "status": "pending",
    "url": "https://www.atribu.app/h/9tQ2mB1x…",
    "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…" }
}

The URL opens the same click-wrap the Atribu console shows — the full agreement, a scroll-to-the-bottom gate, and a name and email for the signer. Accepting records the signer's email, IP address, user agent, the timestamp and the document version.

Then poll GET /api/v1/handoffs/{id} until status leaves pending, and read the record back from GET /api/v1/conversion-sync/legal.

Already signed? You get completed, not an error

A hand-off minted for a document that is already accepted comes back settled, in the same call:

Already accepted
{
  "data": {
    "kind": "sign_dpa",
    "status": "completed",
    "url": null,
    "result": {
      "already_accepted": true,
      "document": "dpa",
      "document_version": "2026-09-02-v1",
      "accepted_at": "2026-08-14T09:12:44.000Z"
    }
  }
}

This is the property to build on: re-running a setup checklist must never ask a customer to sign the same agreement twice, and it never will. url is null because there is nothing for anyone to open.

The DPA and the BAA are separate

They write different columns, and that separation is the point: accepting the DPA can never be mistaken for a BAA approval, which is what unlocks Healthcare Mode. A signed DPA does not settle a BAA hand-off, and asking for /baa/handoff on a profile that has only signed the DPA gives you a live URL, correctly.

Accept as a signed-in owner or admin

Endpoint
POST /api/v1/legal/dpa/accept
POST /api/v1/legal/baa/accept

Scope: exports:write · Credential: a Supabase session bearer, held by a workspace owner or admin

This is the Atribu console's own write, published so acceptance has one implementation rather than several. It records the agreement for the person making the call.

Optional body
{ "document_version": "2026-09-02-v1" }

The one field names which of the shipped texts you displayed — the full agreement at /dpa, or the shorter platform-safe click-wrap that gates a profile's privacy mode. It is checked against an allowlist and never recorded as given: the stored version says which agreement a customer accepted, and it cannot do that job if the customer picks the value. Omit it to accept the current text.

Success response (200 OK)
{
  "data": {
    "document": "dpa",
    "document_version": "2026-09-02-v1",
    "accepted_at": "2026-09-05T10:45:12.000Z",
    "already_accepted": false
  },
  "meta": { "profile_id": "8f3d…" }
}

Idempotent at the document grain. Accepting an already-accepted document writes nothing, answers already_accepted: true, and returns the ORIGINAL timestamp — a reload is not a second agreement.

Why an API key gets a 403 here

403 insufficient_scope
{
  "error": {
    "code": "insufficient_scope",
    "message": "A DPA or BAA can only be accepted by a signed-in workspace owner or admin. An API key cannot agree to a contract on a customer's behalf — mint a signing hand-off with POST /api/v1/legal/dpa/handoff and give the URL to a person.",
    "status": 403
  }
}

A guest who reaches the profile through a profile membership alone is refused for the same reason, however wide their profile permissions: they are not the counterparty to Atribu's agreement.

Reading the result

GET /api/v1/conversion-sync/legal (scope exports:read) returns the full compliance record: dpa_status, baa_status, signature_status, signed_at, the document version, and every stored document with a time-limited signed URL. That is where the signer's name, email, IP and user agent live — on the evidence row rather than on the compliance columns, because the signatory usually has no Atribu account for those columns' foreign keys to point at.

On this page