Attribution Data API

An HTTP API over the curated attribution warehouse — read any dataset, and, with a separate write key, add to the few that accept writes (see Writing). Authenticate with a scoped bearer key and pull JSON — no database connection string required. Every dataset is a curated, allowlisted view; customer/staff PII lives only behind dedicated *_pii scopes, and deal amounts only behind *_fin.

Curated datasets

Cleanly-shaped read-only views — leads, events, activities, attribution, LTV, staff and reference. Filter by any column, paginate, done.

Scoped, PII- & money-gated

Each key is granted only the datasets it needs. Names / emails / phones require a *_pii scope; deal amounts require a *_fin one. The two are independent.

…

Getting started

Three steps: get a key, try a request right here in the browser, then copy the code into your app. No database access, no setup — if you can make an HTTP request, you can use this API.

1

Get an API key

Keys are issued by an egelloC admin from the incubator panel — you can't self-serve one. Ask in your team channel, or if you are an admin:

  • Open incubator.egelloc.com → API Keys tab
  • Find the Attribution API Keys card → Issue API key
  • Name it after the app or person, tick the datasets it needs, create

When you request one, say which datasets you need — the scope name is just the dataset name. Browse them under Datasets in the sidebar. Two things worth knowing:

  • The key is shown once, at creation. Store it somewhere safe immediately; if it's lost it has to be rotated, not recovered.
  • Anything ending in _pii exposes names, emails and phones. Only ask for those if your app genuinely needs to identify people — the non-PII datasets don't even contain those columns.
2

Try it live

Paste your key and run a real request against this API, right now. Pick any dataset — if your key isn't scoped for it you'll get a 403, which is the system working as intended.

ready
Response will appear here.

Your key stays in this browser tab — it is only ever sent to this API, never stored, logged, or shared. Reloading the page clears it.

3

Use it in your code

The same request in the language you're working in. These update to match whatever you picked above. Keep the key in an environment variable — never commit it.

curl
Python
JavaScript

List responses come back enveloped as { items, limit, offset, total } — the rows are in items, and total is the full count before paging. Read Using the API for filtering, pagination and every status code.

Writing

Everything else here is read-only. A few routes add data, each behind its own write scope. A write key is issued separately from read keys, and only by a named few. Nothing here can ever delete a row — every write login was never granted DELETE.

RouteScopeWhat it can do
POST /v1/attr_eventsattr_events_writeAdd one event. Never change or delete one.
PATCH /v1/attr_events/{source_event_id}/fillattr_events_writeFill a field that is still empty. Never overwrites; never touches an amount.
POST /v1/onboarding/recordsonboarding_writeRecord or update one onboarding fact, by source_record_id.
POST /v1/onboarding/documentsonboarding_writeStore one signed document, once.
PUT /v1/coach_engagement/{nest_student_id}coach_engagement_writeSet a student’s coach colour — see the Nest Briefer.
POST /v1/team_notesteam_notes_writeAdd a team note.
DELETE /v1/team_notes/{id}team_notes_writeHide your own note. Nothing is erased.

Events: POST /v1/attr_events

Needs a key scoped attr_events_write, issued separately from read scopes and to far fewer people.

It can add an event. It cannot change or delete one. That is enforced by the database permission rather than by application code — the login behind this route was never granted UPDATE or DELETE, so a bug here cannot rewrite history.

source_event_id is required, and it is the one that matters

Every write carries a source_event_id: a stable id for the thing that happened. Send the same one twice and the second request does nothing and returns created: false with the row that already exists. That is what makes a retry safe.

Derive it from the event, never from the request that reported it, and never generate a fresh one inside a retry loop — that identifies the attempt rather than the event, and removes the protection entirely. Our own ingesters show the convention: Stripe uses the payment-intent id, so the live webhook and the catch-up poller produce the same key for one payment; Whop uses whop:<id>. For a new consumer, <your-system>:<your stable row id>.

Reusing an id with a different payload is a 409, not a silent no-op — otherwise you would get the first event’s result back for a request that never happened.

curl -X POST "$BASE/v1/attr_events" \
  -H "Authorization: Bearer <your-api-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "source_event_id": "onboarding:enr_123",
    "event_type":      "purchase",
    "source_system":   "onboarding",
    "event_timestamp": "2026-09-19T12:00:00Z",
    "amount":          "4500.00",
    "close_lead_id":   "lead_abc123"
  }'
SituationStatusMeaning
new event200created: true and the stored row
same id again200created: false — nothing written, which is what a retry should look like
same id, different data409Names the fields that disagree. Use a new id, or resend the original values
missing a required field400 / 422Says which. Required: source_event_id, event_type, source_system, event_timestamp
read key, or no write scope403A read key cannot write, whatever else it is scoped for

Of the attribution data, only attr_events accepts writes, and that is not a temporary limit. Every other attribution dataset is derived — the per-lead figures are a rollup, the attribution credit is model output — so writing into one would either be recomputed away on the next run, or would make the numbers stop agreeing with the events they came from. The other writable tables below are not attribution figures: they are records other apps keep, and each has a scope of its own.

The Nest Briefer: coach engagement and team notes

The Nest reads and writes the two tables the Coaching Briefer shares with the CX CRM. Two write scopes, each reaching one table: coach_engagement_write and team_notes_write. Behind them is a login of their own that the database lets reach those two tables and nothing else. Read them back with coach_engagement_pii and team_notes_pii.

curl -X PUT "$BASE/v1/coach_engagement/01K8ZQ5V3N2M4P6R8T0W2Y4A6C" \
  -H "Authorization: Bearer <your-write-key>" \
  -H "Content-Type: application/json" \
  -d '{"status": "green", "set_by": "coach@egelloc.com", "reason": "Engaged on every call"}'

curl -X POST "$BASE/v1/team_notes" \
  -H "Authorization: Bearer <your-write-key>" \
  -H "Content-Type: application/json" \
  -d '{"student_id": "01K8ZQ5V3N2M4P6R8T0W2Y4A6C", "author_email": "coach@egelloc.com", "body": "Called the family today."}'

curl -X DELETE "$BASE/v1/team_notes/901?author_email=coach@egelloc.com" \
  -H "Authorization: Bearer <your-write-key>"
RuleWhy
status is red, yellow or greengrey is refused: it means nobody has marked the student
set_by / author_email is an emailThe person who did it, as every row the Briefer writes
the time is the server’sNobody can backdate a colour or a note
same colour and reason againoutcome: unchanged, nothing written
same note again within 10 minutesReturns the note already stored, so a retry never makes a twin
a note’s source is always nestSet by the API, so every reader can tell who wrote it
notes are never editedHide one and add a new one. Only its author can hide it; a hidden note is gone from every reader but never erased

Testing safely: ?dry_run=true

These routes write to the real tables, and there is no sandbox copy. So each of the three accepts ?dry_run=true: the call does everything for real — your key, its scope, every rule above and the actual database write — then always undoes it. The answer is exactly what a real call would return, plus "dry_run": true. Nothing is saved.

curl -X POST "$BASE/v1/team_notes?dry_run=true" \
  -H "Authorization: Bearer <your-write-key>" \
  -H "Content-Type: application/json" \
  -d '{"student_id": "01K8ZQ5V3N2M4P6R8T0W2Y4A6C", "author_email": "coach@egelloc.com", "body": "Testing."}'

Two things to know. Dry runs don’t see each other: a note “added” in one is gone at once, so to try a removal, dry-run it on a real note. And a dry-run note’s id is real but never used, so real ids skip it.

The advisor’s sales notes reach the Nest through attr_advisor_notes_pii, with every amount blanked ([amount]) — pricing stays off staff-facing profiles. On a Sales Won activity the same rows also carry the deal’s sales_notes, interested_products, special_requests_for_coach and red_flags_or_parent_dynamic, as plain text, amounts blanked the same way.

Using the API

Every dataset route is a read-only GET returning JSON over HTTPS. Base URL /v1. A few routes write, each behind its own write scope — see Writing — and none can delete a row.

Authentication

Send a bearer key on every data route (this catalog and /v1/health need none). Keys are scoped per dataset — the scope name equals the dataset name (e.g. attr_activity, attr_leads).

Authorization: Bearer <your-api-key>
SituationStatusMeaning
valid key, in scope200Data returned
missing / bad key401No or invalid Authorization header
wrong scope403Key not scoped for that dataset
unknown id / dataset404No matching row, or dataset not in the allowlist
bad filter column400Filter column not on that dataset

Scopes, PII & money

Scoping is the only access mechanism. It gates two sensitive things independently — who someone is, and what a deal is worth:

  • Scoped — every dataset is its own scope. A key reads only the datasets it was granted; anything else is a 403.
  • PII-gated — customer/staff PII (names, emails, phones, raw notes) lives only in the separate *_pii datasets. A key without a *_pii scope never sees PII — and the non-PII view doesn't even contain those columns.
  • Money-gated — deal amounts live only in the *_fin datasets (the Money group). attr_leads carries no total_revenue and attr_activity no program total, initial payment, instalment amounts or purchase amount. What stays is the shape of a deal — payment type, how many instalments, their dates, product, purchase date — which is what funnel work needs and reveals no figure. Join attr_leads_fin on close_lead_id and attr_activity_fin on id.

Identity and money are independent grants, not a ladder: a key can have names without amounts, amounts without names, or neither. One exception worth knowing — attr_activity_pii returns the raw custom_data blob, which still contains the amounts, so it is not a revenue-free dataset.

So an app that doesn't need PII is granted only the plain scopes and cannot retrieve it. Example — a sales-performance dashboard:

AppGranted scopesCan read
sales dashboard (no PII)attr_activity attr_leads attr_leads_utm attr_events attr_staffbookings, advisors, funnel, marketing source — no names/emails/phones, and no deal amounts
same dashboard, reporting revenueabove + attr_activity_fin attr_leads_finadds the amounts, still with no identity fields
app that needs contactsabove + crm_leads_piiadds contact/identity fields, for that key only

Pagination & filtering

ParamTypeDescription
limitintegerRows to return. Default 100, max 1000
offsetintegerRows to skip. Default 0. Fine for the first few pages; see after for large pulls
aftercursorResume from the previous page's next_after. Stays fast at any depth. Can't be combined with offset
{column}={value}anyAny real column becomes an equality filter (else 400)
{column}__gte / __lteanyRange: >= / <=. Combine both for a closed interval
{column}__gt / __ltanyRange, exclusive: > / <
{column}__inanySet membership: ?activity_type_name__in=AC%20Booked,AC%20Completed. Comma-separated, up to 200 values

Every filter is ANDed, including repeats of the same column — that is what makes a two-sided range work: ?date_created__gte=2026-07-01&date_created__lte=2026-07-31 is one closed interval. It also means a repeated equality (?id=a&id=b) matches nothing rather than quietly picking one. For equality use the bare ?column=value; there is no __eq.

__in replaces N requests with one — ?activity_type_name__in=AC%20Booked,AC%20Completed,AC%20Not%20Completed instead of three calls or pulling everything and filtering locally. It works on the aggregate endpoint too. Two caveats: since every filter is ANDed, repeating __in on the same column is an intersection, not a union; and a value that itself contains a comma cannot be expressed — there is no escape character. An empty list (?col__in=) is a 400, not an empty page, because a blank variable is a far likelier explanation than a deliberate request for no rows.

Careful with dates on timestamp columns. A bare date parses as midnight, so __lte=2026-07-31 means <= 2026-07-31 00:00:00 and silently drops almost all of the 31st. For a whole month use a half-open interval — ?date_created__gte=2026-07-01&date_created__lt=2026-08-01 returns 58,332 rows where the __lte version returns 55,917.

Range filters are how you pull a large dataset affordably. Walking all of attr_activity is ~799 pages; one month is ~58. The first request for a new range pays for its total (~8 s on attr_activity), then it's cached for 5 minutes; pass after and the count is skipped entirely. A value the column's type can't parse returns 400 with Postgres' own explanation (e.g. invalid input syntax for type timestamp: "notadate"), and a range on a type that has no such operator (like a jsonb column) also returns 400.

List responses are enveloped: { items, limit, offset, total, next_after, total_cache_ttl_seconds }. Single-record routes (/v1/{dataset}/{id}) return the row object directly.

total is cached; the rows are not. Counting is the slowest part of a list request, so the figure is cached per dataset + filter set and may be up to total_cache_ttl_seconds old — 300 by default. If you walk a dataset and your own tally disagrees with total by a little, that is this, plus anything the ingest wrote while you were walking. It is not drift. total and the TTL are both null on cursor pages (?after=), where the count is not recomputed at all.

total counts the rows matching your filters, ignoring paging. Counting a large table is genuinely expensive, so the number is cached for up to 5 minutes — treat it as an accurate size for paging and progress, not as a live figure to reconcile against. items is never cached and is always read fresh.

Ordering — read this before paging

Every dataset returns rows in a fixed order, so walking offset=0,100,200… visits each row exactly once. Most sort on a unique id; some have a composite grain and sort on several columns instead. Each dataset's full sort key is published in /v1/catalog as order_by, in order of precedence.

Cursors are opaque. For a single-column sort key next_after is just that column's value; for a composite one it is an encoded token covering every column in the key. Pass it back exactly as you received it and don't parse, build, or reuse one across datasets — a cursor from the wrong dataset is a 400, not a silently wrong page.

Every dataset now reports stable_order: true. attr_leads_utm used to be the exception — it has no natural unique column — and it now sorts on row_key, an opaque per-row id. Treat row_key as a cursor and a row identity, nothing more: it is a salted hash, it carries no meaning, and it does not join to anything else. Use close_lead_id to join that dataset to the rest.

Pulling a whole dataset — use the cursor

offset makes the database walk and throw away every row it skips, so it collapses with depth. On attr_activity, offset=50000 already fails with a 504 at the gateway — this is not something you can wait out. Pass after instead and the index turns the same page into a seek: ~1 second, flat at any depth.

Every response carries next_after. Feed it back as ?after=… and keep going until it comes back null, which means you've reached the end:

# walk an entire dataset, constant time per page
cursor = None
while True:
    url = f"/v1/attr_activity?limit=1000"
    if cursor: url += f"&after={cursor}"
    page = requests.get(url, headers=hdrs).json()
    handle(page["items"])
    cursor = page["next_after"]
    if not cursor: break

Works on every dataset, attr_leads_utm included (it pages on row_key; all 116,406 rows walk in ~2 minutes).

total is null on cursor pages. Counting the rows is the slowest part of a request (~2 s on the largest dataset, against ~350 ms for the data itself) and the answer never changes as you walk — so you get it on the first page and it isn't recomputed on the rest. Read total from your first response and keep it.

Errors

Every failure returns the same JSON shape, with the reason in detail. Nothing else is added, so you can branch on the status code and log detail verbatim.

HTTP 403
{ "detail": "API key lacks required scope 'attr_leads'" }

A 403 names the scope you're missing — hand that string to whoever issues your key and they know exactly what to grant.

Limits

LimitValueNotes
rate limitnoneNo request throttling. Be reasonable — it's a shared warehouse, and a runaway loop is felt by other apps.
rows per request1000limit above this is silently capped, not rejected. Default 100.
deep offsets504The database walks every skipped row, and the gateway gives up first — offset=50000 already fails on the big datasets. Use after.

How fresh is the data?

This API reads the warehouse directly, so it is exactly as current as the last sync — there is no caching layer of its own. Sync cadence, all times UTC:

FeedsCadence
Close activities & leads sync, Stripe and Whop pollerscontinuous
Staff / user directoryevery 15 min
Attribution pipeline and the nightly CRM syncdaily 02:00
Extended CRM syncTuesdays 02:45
Marketing / UTM source dataSundays 03:00

Treat the cadence as a guide and the data as the source of truth. For an authoritative answer, read the row's own timestamp — last_updated on the lead aggregates, date_updated on Close records, computed_at on attribution results, updated_at on UTM. A daily job that failed will show up there and nowhere else.

Summarising without pulling every row

Every dataset also has an /aggregate endpoint, so you can ask for a total or a breakdown instead of downloading the rows and adding them up yourself:

curl -H "Authorization: Bearer $KEY" \
  "https://attribution-data-api.egelloc.com/v1/attr_activity/aggregate?group_by=activity_type_name&metrics=count&date_created__gte=2026-07-01&date_created__lte=2026-07-31"

group_by takes up to three columns (omit it for a grand total). A date or timestamp column can carry a time bucket — group_by=date_created:week — which is how you get a trend rather than one group per distinct instant. Units: hour, day, week, month, quarter, year. The bucketed key comes back as date_created_week, so it is never confused with the raw column, and groups are ordered chronologically.

metrics takes any of:

MetricNotes
countrows in the group — the default if you pass no metrics
count_distinct:columnunique values. The most expensive metric by far — always pair it with a filter
sum:column · avg:columnnumeric columns only
min:column · max:columnany sortable column, including dates and text

Money is mostly text. Amounts pulled out of custom_data are stored as text, so sum on them returns a 400 telling you so. The typed revenue columns live on the _fin datasets — sum:total_revenue on attr_leads_fin works.

Filter it, or it may time out. The same filters as the list endpoint apply, and they matter more here: an aggregate reads every matching row. Unfiltered it scans the whole dataset, and you may get a 504 asking you to narrow it — a date range is the usual fix. Figures may be up to 5 minutes old (the response tells you, in cache_ttl_seconds), and truncated: true means there were more groups than the limit returned.

Test leads are excluded — figures match the dashboards

Internal test and staff leads are filtered out server-side, using the same rule the marketing and sales dashboards apply. A figure you pull here should tie out with the same figure on a dashboard. A lead is excluded if any of these hold:

RuleCatches
any @egelloc.com address, primary or secondarystaff and most QA bookings
display name starting ZZQAthe QA naming convention
test@test.comgeneric throwaway funnel tests

This affects attr_activity (and its _pii / _fin twins) and crm_leads (and _pii) — about 120 leads and 790 activities, roughly 0.1%. There is no way to opt back in; if you need to see test leads, ask the API owner. Datasets built on the attribution pipeline's own tables are unaffected, since no dashboard reads those.

Getting help

For a key, a new scope, or a 403 you think is wrong, go to whoever issued your key — keys are managed by egelloC admins in the incubator panel, and the issuer can see and change your scopes. For data that looks wrong or stale, quote the row's timestamp column and the dataset name.

Health & docs

curl -s /v1/health     → {"status":"ok"}
curl -s /v1/health/db  → {"status":"ok","db":"ok"}

Interactive OpenAPI docs: /docs · /redoc · /openapi.json.