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
GET /api/v1/customersReturns 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
| Parameter | Type | Required | Description |
|---|---|---|---|
date_from | string | Yes | Start date in YYYY-MM-DD format. |
date_to | string | Yes | End date in YYYY-MM-DD format. |
goal | string | Yes | Conversion type: payment_received, lead_created, appointment_booked, closed_won, etc. |
search | string | No | Search by customer name or email. |
limit | number | No | Results per page. Default 10, max 100. |
cursor_time | string | No | Pagination cursor timestamp (from previous response). |
cursor_id | string | No | Pagination cursor ID (from previous response). |
Request
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"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();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
{
"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
| Field | Type | Description |
|---|---|---|
conversion_id | string | Unique ID of this conversion event. |
customer_profile_id | string | Customer's profile UUID. Use this for the Journey endpoint. |
name | string | Customer's full name (may be null for anonymous visitors). |
email | string | Customer's email address (may be null). |
country | string | ISO country code from the converting session. |
device | string | Device type: desktop, mobile, tablet. |
channel | string | Marketing channel of the last touch before conversion. |
source | string | Traffic source (e.g., ig, fb, google). |
revenue | number | Revenue attributed to this specific conversion. |
revenue_type | string | cash, pipeline, or gross. |
conversion_time | string | ISO 8601 timestamp of the conversion. |
time_to_complete_seconds | number | Seconds between first touch and conversion. |
touch_count | number | Number of marketing touchpoints before conversion. |
touch_channels | string[] | Distinct channels across all touchpoints. |
conversion_count | number | Total conversions by this customer (lifetime). |
total_revenue | number | Total 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 -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"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("|");
}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
POST /api/v1/customersBatch-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
| Field | Type | Required | Description |
|---|---|---|---|
country | string | No | ISO 3166-1 alpha-2 hint for the WHOLE batch's national_id parsing. Defaults to CL — the only country supported today. |
rows | array | Yes | Up to 1,000 row objects (see below). |
Each entry in rows:
| Field | Type | Required | Description |
|---|---|---|---|
phone | string | See above | Normalised to E.164 when parseable. |
email | string | See above | Normalised (lowercased, trimmed). |
national_id | string | See above | A government identity document number (e.g. a Chilean RUT). |
external_id | string | No | YOUR customer id (PMS/CRM primary key). Never enough alone. |
first_name | string | No | Only filled in on a profile that doesn't already have one. |
last_name | string | No | Same rule as first_name. |
Request
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" }
]
}'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();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
{
"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
| Field | Type | Description |
|---|---|---|
results[].index | number | The row's position in the request's rows array. |
results[].status | string | created, matched (resolved to an existing profile), or rejected. |
results[].customer_profile_id | string | The identity the row resolved to, or null when rejected. |
results[].reason | string | Present only on a rejected row. |
summary | object | Row-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
GET /api/v1/customers/{id}/journeyReturns 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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Customer profile UUID (path parameter). |
offset | number | No | Skip the first N events. Default 0. |
limit | number | No | Maximum 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 -H "Authorization: Bearer atb_live_YOUR_KEY" \
"https://www.atribu.app/api/v1/customers/customer-uuid/journey?limit=20"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`);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
{
"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
| Field | Type | Description |
|---|---|---|
event_type | string | Event category (page_view, lead_created, payment_received, etc.). |
event_name | string | Specific event name. |
event_time | string | ISO 8601 timestamp. |
url | string | Full page URL (web events only). |
path | string | URL path component. |
channel | string | Classified marketing channel. |
source | string | Traffic source or payment provider. |
medium | string | Marketing medium (paid, organic, referral, etc.). |
campaign | string | Campaign name (resolved from platform ID). |
value_amount | number | Monetary value (for payment and deal events). |
currency | string | ISO 4217 currency code. |
device | string | Device type. |
browser | string | Browser name. |
os | string | Operating system. |
country | string | ISO country code. |
city | string | City name. |
is_synthetic | boolean | true for off-site conversions that had no website visit. |
total_count | number | Total 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
GET /api/v1/customers/journey?customer_key={your_customer_id}
GET /api/v1/conversions/{id}/journeyThe 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.
Customer Search
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
| Parameter | Type | Description |
|---|---|---|
q | string | Required. The search term. Under 2 characters returns []. |
exclude | uuid | A customer id to leave out — typically the one already selected. |
limit | number | 1-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.
{id} is the same visitor id the customer list publishes — a customer id, or
an anonymous id for a visitor who was never identified.
Link Identity Tokens
POST /api/v1/customers/link-tokensMints 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
| Field | Type | Required | Description |
|---|---|---|---|
customers | array | Yes | Up to 500. Each names ONE customer by customer_profile_id, email, phone, or identifier_type + identifier_value (e.g. ghl_contact_id). |
expires_in_days | number | No | 1–365, default 90. Match the life of the campaign or sequence. |
country | string | No | ISO 3166-1 alpha-2 default for phones without a + country code. |
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": 90,
"customers": [
{ "identifier_type": "ghl_contact_id", "identifier_value": "pQ1x9Lz3" },
{ "email": "[email protected]" }
]
}'Response
{
"data": {
"param": "atb_t",
"minted": 1,
"not_found": 1,
"invalid": 0,
"results": [
{ "index": 0, "status": "minted", "customer_profile_id": "uuid", "token": "AZ3kq0sV1…", "expires_at": "2026-12-23T12:00:00.000Z" },
{ "index": 1, "status": "not_found" }
],
"recipes": [ { "channel": "gohighlevel", "store_token_in": "…", "link_template": "…", "merge_tag": "…" } ]
},
"meta": { "profile_id": "uuid" }
}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=.
| Tool | Save the token in | Link |
|---|---|---|
| GoHighLevel | Contact custom field atribu_link_token | https://your-site.com/offer?atb_t={{contact.atribu_link_token}} |
| Klaviyo | Profile property atribu_link_token | https://your-site.com/offer?atb_t={{ person|lookup:'atribu_link_token' }} |
| WhatsApp template | The dynamic URL button variable | Button URL https://your-site.com/offer?atb_t={{1}}; your sender fills {{1}} with the token |
The Atribu tracker must be installed on the landing page. Links that point at a page without it are not linked.