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.

The DPA for a partner app

A partner app that provisions profiles for its own customers (POST /api/v1/profiles, Partners) creates headless profiles: their owners never sign in to Atribu, so nobody could accept the DPA for them. For those profiles the DPA counterparty is the partner, so the partner accepts it once, for the app.

  • Where. An owner or admin of the workspace that administers the app (owner_workspace_id, set by Atribu when the app is registered) opens Workspace settings → Compliance in the console. The "Partner apps" card lists each app with its DPA state and how many of its profiles the acceptance covers, and shows the same Platform-Safe click-wrap as the profile gate.
  • What it writes. In one transaction, every profile the app provisioned gets dpa_status: accepted with the app's document version, timestamp and acceptor. Every profile the app provisions later is accepted at birth, and every idempotent POST /api/v1/profiles replay re-applies it. The wiring read's platform_safe.dpa_status, the provision checks.dpa_status and the export legal gate all read the same field, so sign_dpa stops appearing for those profiles.
  • Direct acceptances win. A profile whose own owner already accepted the DPA keeps that acceptance — its version, timestamp and signer — and is never overwritten by the app.
  • Withdrawing. The same card withdraws it. Every profile that held the DPA through the app returns to dpa_status: withdrawn, and Atribu holds its Platform-Safe exports again; direct acceptances are untouched.
  • Credentials. A browser session only. An API key never reaches these routes, and an MCP user token is refused with 403 insufficient_scope, for the reason given at the top of this page.
Endpoints
GET  /api/v1/workspaces/{workspaceId}/oauth-apps
POST /api/v1/workspaces/{workspaceId}/oauth-apps/{appId}/dpa/accept
POST /api/v1/workspaces/{workspaceId}/oauth-apps/{appId}/dpa/withdraw

accept takes the same optional { "document_version": "…" } body as the profile accept and is idempotent the same way: accepting an accepted app answers changed: false and keeps the original dpa_accepted_at. Operations read the app's state on GET /api/v1/admin/oauth-apps/{id} (dpa_status, dpa_version, dpa_accepted_at, dpa_accepted_by, read only).

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