Atribu
API Reference

Customers & Journey

Paginated customer list with conversion data, and full event timeline per customer.

PII scope required

The read endpoints on this page return personally identifiable information (names, emails). Your API key must have the customers:read scope explicitly granted. Keys without this scope will receive a 403 Forbidden response. The Import endpoint is a write and uses a separate scope, attribution:write — see its section below.


Customer List

Endpoint
GET /api/v1/customers

Returns a paginated list of customers who achieved a specific conversion goal within the date range. Each row includes the customer's identity, the conversion details, channel attribution, and lifetime metrics.

Scope: customers:read

Parameters

ParameterTypeRequiredDescription
date_fromstringYesStart date in YYYY-MM-DD format.
date_tostringYesEnd date in YYYY-MM-DD format.
goalstringYesConversion type: payment_received, lead_created, appointment_booked, closed_won, etc.
searchstringNoSearch by customer name or email.
limitnumberNoResults per page. Default 10, max 100.
cursor_timestringNoPagination cursor timestamp (from previous response).
cursor_idstringNoPagination cursor ID (from previous response).

Request

cURL
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/customers?date_from=2026-03-01&date_to=2026-03-25&goal=payment_received&limit=5"
JavaScript
const res = await fetch(
  "https://www.atribu.app/api/v1/customers?date_from=2026-03-01&date_to=2026-03-25&goal=payment_received&limit=5",
  { headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
const { data, pagination } = await res.json();
Python
import requests

res = requests.get(
    "https://www.atribu.app/api/v1/customers",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={
        "date_from": "2026-03-01",
        "date_to": "2026-03-25",
        "goal": "payment_received",
        "limit": 5,
    },
)
body = res.json()
data = body["data"]
has_next = body["pagination"]["has_next"]

Response

Success response (200 OK)
{
  "data": [
    {
      "conversion_id": "uuid",
      "customer_profile_id": "uuid",
      "name": "Jane Smith",
      "email": "[email protected]",
      "country": "US",
      "device": "mobile",
      "channel": "Paid Social",
      "source": "ig",
      "revenue": 299.00,
      "revenue_type": "cash",
      "conversion_time": "2026-03-22T14:30:00Z",
      "time_to_complete_seconds": 172800,
      "touch_count": 3,
      "touch_channels": ["Paid Social", "Direct"],
      "conversion_count": 2,
      "total_revenue": 598.00
    }
  ],
  "pagination": {
    "has_next": true,
    "cursor": "2026-03-22T14:30:00Z|uuid"
  },
  "meta": {
    "date_from": "2026-03-01",
    "date_to": "2026-03-25",
    "profile_id": "uuid"
  }
}

Response fields

FieldTypeDescription
conversion_idstringUnique ID of this conversion event.
customer_profile_idstringCustomer's profile UUID. Use this for the Journey endpoint.
namestringCustomer's full name (may be null for anonymous visitors).
emailstringCustomer's email address (may be null).
countrystringISO country code from the converting session.
devicestringDevice type: desktop, mobile, tablet.
channelstringMarketing channel of the last touch before conversion.
sourcestringTraffic source (e.g., ig, fb, google).
revenuenumberRevenue attributed to this specific conversion.
revenue_typestringcash, pipeline, or gross.
conversion_timestringISO 8601 timestamp of the conversion.
time_to_complete_secondsnumberSeconds between first touch and conversion.
touch_countnumberNumber of marketing touchpoints before conversion.
touch_channelsstring[]Distinct channels across all touchpoints.
conversion_countnumberTotal conversions by this customer (lifetime).
total_revenuenumberTotal revenue from this customer (lifetime).

Pagination

This endpoint uses cursor-based pagination. The pagination.cursor field in the response contains the values needed to fetch the next page.

Fetch the first page with your desired limit.

Check pagination.has_next. If true, split pagination.cursor by | to get cursor_time and cursor_id.

Pass both values in the next request.

cURL — fetch page 2
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/customers?date_from=2026-03-01&date_to=2026-03-25&goal=payment_received&cursor_time=2026-03-22T14:30:00Z&cursor_id=uuid"
JavaScript — paginate through all results
let allCustomers = [];
let cursorTime = undefined;
let cursorId = undefined;

while (true) {
  const url = new URL("https://www.atribu.app/api/v1/customers");
  url.searchParams.set("date_from", "2026-03-01");
  url.searchParams.set("date_to", "2026-03-25");
  url.searchParams.set("goal", "payment_received");
  url.searchParams.set("limit", "50");
  if (cursorTime) url.searchParams.set("cursor_time", cursorTime);
  if (cursorId) url.searchParams.set("cursor_id", cursorId);

  const res = await fetch(url, {
    headers: { Authorization: "Bearer atb_live_YOUR_KEY" },
  });
  const body = await res.json();
  allCustomers.push(...body.data);

  if (!body.pagination.has_next) break;
  [cursorTime, cursorId] = body.pagination.cursor.split("|");
}
Python — paginate through all results
import requests

all_customers = []
cursor_time = None
cursor_id = None

while True:
    params = {
        "date_from": "2026-03-01",
        "date_to": "2026-03-25",
        "goal": "payment_received",
        "limit": 50,
    }
    if cursor_time:
        params["cursor_time"] = cursor_time
        params["cursor_id"] = cursor_id

    body = requests.get(
        "https://www.atribu.app/api/v1/customers",
        headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
        params=params,
    ).json()

    all_customers.extend(body["data"])

    if not body["pagination"]["has_next"]:
        break
    cursor_time, cursor_id = body["pagination"]["cursor"].split("|")

Import Customers

Endpoint
POST /api/v1/customers

Batch-upserts customer identities directly into the identity graph — a practice-management export (Dentalink, Medilink, a spreadsheet) or a CSV, not an event. No outcome_events row is written and no event name is picked; this is the write sibling of the Customer List endpoint above.

Scope: attribution:write

Every row needs at least one strong identifier

A row must carry phone, email, or national_id. A row with only external_id is rejected, not silently dropped and not used to create a profile — a profile minted from a foreign key alone can never be merged with the real person later.

Request body

FieldTypeRequiredDescription
countrystringNoISO 3166-1 alpha-2 hint for the WHOLE batch's national_id parsing. Defaults to CL — the only country supported today.
rowsarrayYesUp to 1,000 row objects (see below).

Each entry in rows:

FieldTypeRequiredDescription
phonestringSee aboveNormalised to E.164 when parseable.
emailstringSee aboveNormalised (lowercased, trimmed).
national_idstringSee aboveA government identity document number (e.g. a Chilean RUT).
external_idstringNoYOUR customer id (PMS/CRM primary key). Never enough alone.
first_namestringNoOnly filled in on a profile that doesn't already have one.
last_namestringNoSame rule as first_name.

Request

cURL
curl -X POST "https://www.atribu.app/api/v1/customers" \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "country": "CL",
    "rows": [
      { "phone": "+56911112222", "national_id": "14.146.609-7", "first_name": "María", "last_name": "Pérez" },
      { "email": "[email protected]", "national_id": "20473794-0" }
    ]
  }'
JavaScript
const res = await fetch("https://www.atribu.app/api/v1/customers", {
  method: "POST",
  headers: {
    Authorization: "Bearer atb_live_YOUR_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    country: "CL",
    rows: [
      { phone: "+56911112222", national_id: "14.146.609-7", first_name: "María" },
      { email: "[email protected]", national_id: "20473794-0" },
    ],
  }),
});
const { data } = await res.json();
Python
import requests

res = requests.post(
    "https://www.atribu.app/api/v1/customers",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    json={
        "country": "CL",
        "rows": [
            {"phone": "+56911112222", "national_id": "14.146.609-7", "first_name": "María"},
            {"email": "[email protected]", "national_id": "20473794-0"},
        ],
    },
)
data = res.json()["data"]

Response

Success response (200 OK)
{
  "data": {
    "results": [
      { "index": 0, "status": "created", "customer_profile_id": "uuid" },
      { "index": 1, "status": "matched", "customer_profile_id": "uuid" }
    ],
    "summary": { "total": 2, "created": 1, "matched": 1, "rejected": 0 }
  },
  "meta": { "profile_id": "uuid" }
}

Response fields

FieldTypeDescription
results[].indexnumberThe row's position in the request's rows array.
results[].statusstringcreated, matched (resolved to an existing profile), or rejected.
results[].customer_profile_idstringThe identity the row resolved to, or null when rejected.
results[].reasonstringPresent only on a rejected row.
summaryobjectRow-status counts for the whole batch.

Degrades visibly, never drops

Every row gets a result, in request order. A malformed or under-identified row never aborts the batch and never disappears silently — check each row's own status.


Customer Journey

Endpoint
GET /api/v1/customers/{id}/journey

Returns the full event timeline for a single customer -- every page view, form submission, booking, payment, and marketing touchpoint in chronological order.

Scope: customers:read

Parameters

ParameterTypeRequiredDescription
idstringYesCustomer profile UUID (path parameter).
offsetnumberNoSkip the first N events. Default 0.
limitnumberNoMaximum events to return. Default 50, max 100.

BOLA protection

This endpoint enforces object-level authorization. If the customer ID does not belong to your profile, it returns empty data (not an error). This prevents information disclosure about whether specific customer IDs exist in the system.

Request

cURL
curl -H "Authorization: Bearer atb_live_YOUR_KEY" \
  "https://www.atribu.app/api/v1/customers/customer-uuid/journey?limit=20"
JavaScript
const customerId = "customer-uuid";
const res = await fetch(
  `https://www.atribu.app/api/v1/customers/${customerId}/journey?limit=20`,
  { headers: { Authorization: "Bearer atb_live_YOUR_KEY" } }
);
const { data } = await res.json();
console.log(`${data.total_count} events in journey`);
Python
import requests

customer_id = "customer-uuid"
res = requests.get(
    f"https://www.atribu.app/api/v1/customers/{customer_id}/journey",
    headers={"Authorization": "Bearer atb_live_YOUR_KEY"},
    params={"limit": 20},
)
events = res.json()["data"]["events"]

Response

Success response (200 OK)
{
  "data": {
    "events": [
      {
        "event_type": "page_view",
        "event_name": "page_view",
        "event_time": "2026-03-18T10:15:00Z",
        "url": "https://example.com/pricing",
        "path": "/pricing",
        "channel": "Paid Social",
        "source": "ig",
        "medium": "paid",
        "campaign": "Spring Sale",
        "value_amount": null,
        "currency": null,
        "device": "mobile",
        "browser": "Safari",
        "os": "iOS",
        "country": "US",
        "city": "New York",
        "is_synthetic": false
      },
      {
        "event_type": "payment_received",
        "event_name": "payment_received",
        "event_time": "2026-03-20T16:45:00Z",
        "url": null,
        "path": null,
        "channel": null,
        "source": "stripe",
        "medium": null,
        "campaign": null,
        "value_amount": 299.00,
        "currency": "USD",
        "device": null,
        "browser": null,
        "os": null,
        "country": null,
        "city": null,
        "is_synthetic": false
      }
    ],
    "total_count": 12
  },
  "meta": {
    "profile_id": "uuid"
  }
}

Response fields

FieldTypeDescription
event_typestringEvent category (page_view, lead_created, payment_received, etc.).
event_namestringSpecific event name.
event_timestringISO 8601 timestamp.
urlstringFull page URL (web events only).
pathstringURL path component.
channelstringClassified marketing channel.
sourcestringTraffic source or payment provider.
mediumstringMarketing medium (paid, organic, referral, etc.).
campaignstringCampaign name (resolved from platform ID).
value_amountnumberMonetary value (for payment and deal events).
currencystringISO 4217 currency code.
devicestringDevice type.
browserstringBrowser name.
osstringOperating system.
countrystringISO country code.
citystringCity name.
is_syntheticbooleantrue for off-site conversions that had no website visit.
total_countnumberTotal events in the journey (use with offset/limit for paging).

Synthetic touchpoints

Events with is_synthetic: true represent off-site conversions (e.g., Meta lead forms submitted via Instagram that flow into GoHighLevel without a website visit). Atribu creates synthetic touchpoints for these so they can still be attributed to the originating ad campaign. See Synthetic Touches for details.

Journey by Your Customer Key

Endpoints
GET /api/v1/customers/journey?customer_key={your_customer_id}
GET /api/v1/conversions/{id}/journey

The first returns one person's whole timeline, named by the customer id you send as user_traits.external_id on every event, so you never store Atribu's customer id. The second returns the timeline behind one conversion, named by its outcome_event_id, a conversions.id, or your own event key.

Scope: conversions:read or customers:read. The scope decides the shape: with customers:read, the rows above plus the person's name and identifiers in meta.person; without it, a projection with the touches, channels, campaigns, ad sets, ads and credit, and no person, page, device, location or session fields. ?email= / ?phone= instead of customer_key need customers:read. The projected shape is documented in Partner apps.

A key that names nobody in this profile (unknown, erased, or another profile's customer) answers 404 customer_not_found; a conversion handle that names nothing answers 404 conversion_not_found. Both are distinct from not_found, which also means "this deployment does not serve that route".

GET /api/v1/customers/search — find a customer by name, email or phone.

Built for a picker: an operator choosing which customer an unlinked conversation or payment belongs to. A query shorter than two characters returns an empty list rather than the whole book, so it is safe to call on every keystroke behind a short debounce.

Parameters

ParameterTypeDescription
qstringRequired. The search term. Under 2 characters returns [].
excludeuuidA customer id to leave out — typically the one already selected.
limitnumber1-50, default 20.

Requires the customers:read scope, the same tier as the customer list.

Each candidate carries customer_profile_id, first_name, last_name, primary_email, primary_phone, first_seen_at, last_seen_at, conversion_count, cash_revenue and sources. cash_revenue is an exact decimal string — parse it before doing arithmetic.

Journey Summary

GET /api/v1/customers/{id}/journey-summary returns the stored AI summary of one customer's journey, and never calls a model: a null summary with cached: false means none has been generated yet.

POST /api/v1/customers/{id}/journey-summary generates one from the first 100 events of the timeline and stores it, replacing any previous summary. It draws one unit from the workspace's daily AI allowance; when that is spent the call answers 429 with a Retry-After naming the next UTC midnight. A customer with no events returns a null summary and costs nothing.

A healthcare-family profile — its workspace is a healthcare agency, or the profile runs in platform_safe or hipaa privacy mode — never sends a patient's journey to a model: the call answers 403 forbidden, and so does a profile whose status cannot be read.

{id} is the same visitor id the customer list publishes — a customer id, or an anonymous id for a visitor who was never identified.

Endpoint
POST /api/v1/customers/link-tokens

Mints an atb_t token for each customer you name, to put on the links in your emails, WhatsApp messages and CRM sequences. When the customer clicks — on their phone, their laptop, any device — the Atribu tracker reads the token, removes it from the address bar, and links that browser to the customer. The visits it makes from then on, and the anonymous ones it already made, count toward that customer.

Scope: attribution:write

No personal data in your links

The token is encrypted and signed. It never contains an email, a phone or any readable id; two tokens for the same person look unrelated; a token changed by even one character is ignored. It only works on this profile's site, and only until expires_at. This replaces putting ?email= in a URL.

Only customers Atribu already knows get a token — a row that matches nobody comes back not_found, and nothing is created. A browser that is already linked to a different customer is never re-assigned by a token.

Request body

FieldTypeRequiredDescription
customersarrayYesUp to 500 rows. Each row names ONE customer (selectors below).
expires_in_daysnumberNo1–365, default 90. Match the life of the campaign or sequence.
countrystringNoISO 3166-1 alpha-2 default for phones without a + country code, and for national_id.

Each row uses one selector. If a row carries several, the first in this order wins:

SelectorMatches
customer_profile_idAtribu's id for the customer.
customer_keyYour own id for the customer: the user_traits.external_id you send to POST /api/v1/events. Same as identifier_type: "external_customer_id".
emailCase-insensitive.
phoneNormalised to E.164, then exact.
identifier_type + identifier_valueexternal_customer_id, ghl_contact_id, shopify_customer_id, stripe_customer_id or national_id (a Chilean RUT, any formatting).

A selector that is present but malformed makes the row invalid. The row does not fall back to another selector.

cURL
curl -X POST "https://www.atribu.app/api/v1/customers/link-tokens" \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "expires_in_days": 30,
    "customers": [
      { "customer_key": "c9d2e8f1-cust-8841" },
      { "customer_key": "c9d2e8f1-cust-9107" }
    ]
  }'

Response

Success response (200 OK)
{
  "data": {
    "param": "atb_t",
    "minted": 1,
    "not_found": 1,
    "invalid": 0,
    "results": [
      {
        "index": 0,
        "status": "minted",
        "customer_key": "c9d2e8f1-cust-8841",
        "token": "AZ3kq0sV1mN8x4Lr7cTzYbQw2eJp6uHf9gDk5aSx0vWn3oIm8tRc1yUe4hGj7lKb2zXq6pNs9dMf0wVa3rTy5uIo8eQ",
        "expires_at": "2026-10-25T14:00:00.000Z"
      },
      {
        "index": 1,
        "status": "not_found",
        "customer_key": "c9d2e8f1-cust-9107",
        "reason": "no_match",
        "message": "No customer in this profile matches this row. Nothing was created."
      }
    ],
    "recipes": [
      { "channel": "gohighlevel", "store_token_in": "…", "link_template": "…", "merge_tag": "…", "requires": "…" }
    ]
  },
  "meta": { "profile_id": "0b6f1c2e-5d4a-4f3b-9e8d-7c6b5a4f3e2d" }
}

Every row carries customer_key: on a minted row, the key the customer holds (after a merge, the surviving customer's key); on any other row, the key you sent, or null. customer_profile_id appears on minted rows only for a key with customers:read. Partner keys on healthcare profiles never hold that scope, so they never receive it.

A row that did not mint carries a reason your screen can branch on, and an English message. For example, a row sent with an unsupported identifier_type comes back as:

A row that is invalid (excerpt of results[])
{
  "index": 0,
  "status": "invalid",
  "customer_key": null,
  "reason": "unknown_identifier_type",
  "message": "identifier_type must be one of external_customer_id, ghl_contact_id, shopify_customer_id, stripe_customer_id, national_id."
}

All reasons:

statusreasonMeaning
not_foundno_matchNobody in this profile carries that selector. Nothing was created.
not_founderasedThe customer was erased. Only returned for a customer_profile_id you sent, or a customer_key you erased with POST /api/v1/customers/erasures. An email or phone of an erased customer returns no_match.
invalidno_selectorThe row names no selector.
invalidunknown_identifier_typeidentifier_type is not one of the five listed above.
invalidmalformed_selectorA customer_profile_id that is not a UUID, or an email, phone or RUT that cannot be parsed.

A row's status: "not_found" is a per-row result inside a 200 response, not an HTTP error code; the request itself succeeded.

When the deployment has no token secret configured, the endpoint answers 503 with code link_tokens_disabled. The feature is off, not down, so don't retry. Send your links without atb_t.

Merge-tag recipes

No email or messaging tool can create a token at send time, so the setup is the same two steps everywhere: save each customer's token on their contact (from the response above), then add the tool's merge tag to your links. If your link already has a ?, use &atb_t= instead of ?atb_t=.

ToolSave the token inLink
GoHighLevelContact custom field atribu_link_tokenhttps://your-site.com/offer?atb_t={{contact.atribu_link_token}}
KlaviyoProfile property atribu_link_tokenhttps://your-site.com/offer?atb_t={{ person|lookup:'atribu_link_token' }}
WhatsApp templateThe dynamic URL button variableButton URL https://your-site.com/offer?atb_t={{1}}; your sender fills {{1}} with the token
Your own page (partner_page)Nowhere: mint when you build the messagehttps://your-app.com/quotes/q_48213?atb_t={{token}}

The Atribu tracker must be installed on the landing page. Links that point at a page without it are not linked.

Pages your app renders (partner_page)

A partner app that renders the page itself, such as a quote to accept or a booking to confirm, already knows the customer by its own key:

  1. When you build the message, mint by customer_key and put the token on the link: https://your-app.com/quotes/q_48213?atb_t=<token>.
  2. Serve the profile's tracker on that page. Serve it through a same-origin collector, so the visitor id is a first-party cookie that Safari does not cap at 7 days.
  3. When the customer opens the link, the tracker removes atb_t from the address bar and links the browser to the customer. Later visits from that browser, with or without a token, count toward them.

A tampered or expired token is ignored. The page still loads and the visit still counts, just not toward the customer.

Customer erasure by your key

Endpoint
POST /api/v1/customers/erasures
GET  /api/v1/customers/erasures/{erasure_id}

For a partner app that deletes a contact on its side, through its own archive flow or a data-subject request, and must cascade that erasure to Atribu. Call it when your contact is deleted and keep the receipt.

Scope: attribution:write (healthcare keys hold it). The attribution add-on is not required: a profile without it can still erase.

API keys only. A console session or an MCP user token gets 403 api_key_required. To erase as a person, use POST /api/v1/workspaces/{workspaceId}/customer-erasures as a workspace owner or admin. The receipt records the API key that asked.

The erasure removes the customer's personal data from this profile: name, email, phone and every identifier, conversation content, and the personal fields of their raw events. Their conversions, touches and attribution stay, without a person attached, so reports and ROAS do not change. Afterwards:

  • GET /api/v1/customers/journey?customer_key= for that key answers 404 customer_not_found.
  • Link tokens minted for the customer no longer link a browser, and minting by the key answers not_found with reason erased.

It cannot be undone. Conversions already delivered to Meta or Google are outside Atribu's reach.

Request body

FieldTypeRequiredDescription
request_refstringYesYour idempotency key for this erasure, unique per profile. Letters, digits and . _ : -, up to 200 characters. Never put personal data in it; it is stored.
customer_keystringOne selectorYour own id for the customer (the user_traits.external_id you send).
email / phonestringOne selectorExact match. phone is normalised to E.164; add country for numbers without +.
identifier_type + identifier_valuestringOne selectorexternal_customer_id, ghl_contact_id, shopify_customer_id, stripe_customer_id or national_id.
reasonstringNodata_subject_request (default), account_closed or other.

Name the customer with exactly one selector. A body with none, or with two, is refused, because an erasure cannot be undone.

cURL
curl -X POST "https://www.atribu.app/api/v1/customers/erasures" \
  -H "Authorization: Bearer atb_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "request_ref": "crm:contact-archive:8f1a2c",
    "customer_key": "c9d2e8f1-cust-8841",
    "reason": "data_subject_request"
  }'

Response

Erased (202 Accepted)
{
  "data": {
    "erasure_id": "3f0b8c1e-6a2d-4c7b-9e15-2d8a4f6b1c90",
    "request_ref": "crm:contact-archive:8f1a2c",
    "status": "done",
    "customer_key": "c9d2e8f1-cust-8841",
    "reason": "data_subject_request",
    "requested_at": "2026-09-25T18:04:11.201Z",
    "completed_at": "2026-09-25T18:04:11.305Z"
  },
  "meta": { "profile_id": "0b6f1c2e-5d4a-4f3b-9e8d-7c6b5a4f3e2d" }
}
FieldDescription
erasure_idKeep it. GET /api/v1/customers/erasures/{erasure_id} (attribution:write or conversions:read) returns the same receipt.
statusdone once every store is erased. queued while part of it is still completing in the background; the customer is already unreachable by your key.
customer_keyThe key you sent, or the customer's key when you named them by email, phone or another identifier. null if they had none. Every key the customer held (after a merge, several) is recorded as erased.
completed_atWhen status became done; null while queued.

The receipt never carries customer_profile_id, for any key.

StatusWhen
202Erased now.
200The same request_ref again for the same customer (the original receipt, and nothing more is erased), or a new request_ref for any key of a customer you already erased (the first receipt).
400 invalid_parameterrequest_ref missing or malformed, or already used for a different customer; no selector or more than one; an unknown identifier_type; a selector that cannot be parsed; or an unknown reason. The message names the field.
400 invalid_requestThe body is not a JSON object.
403 api_key_requiredThe credential is a console session or an MCP user token.
404 customer_not_foundNo customer of this profile matches. Nothing was erased. A customer of another profile answers the same.

GET /api/v1/customers/erasures/{erasure_id} answers 404 erasure_not_found for an id that is not a receipt of this profile. Both codes are distinct from not_found, which also means "this deployment does not serve that route", so branch on the code, not the status.

Receipts live with the profile: deleting the profile deletes them. Atribu's own audit record of each erasure stays.

On this page