Attribution Data API
A read-only HTTP API over the curated attribution warehouse. 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.
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
_piiexposes 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.
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.
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.
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.
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.
Using the API
All routes are read-only GETs returning JSON over HTTPS. Base URL /v1.
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>
| Situation | Status | Meaning |
|---|---|---|
| valid key, in scope | 200 | Data returned |
| missing / bad key | 401 | No or invalid Authorization header |
| wrong scope | 403 | Key not scoped for that dataset |
| unknown id / dataset | 404 | No matching row, or dataset not in the allowlist |
| bad filter column | 400 | Filter 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
*_piidatasets. A key without a*_piiscope never sees PII — and the non-PII view doesn't even contain those columns. - Money-gated — deal amounts live only in the
*_findatasets (the Money group).attr_leadscarries nototal_revenueandattr_activityno 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. Joinattr_leads_finonclose_lead_idandattr_activity_finonid.
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:
| App | Granted scopes | Can read |
|---|---|---|
| sales dashboard (no PII) | attr_activity attr_leads attr_leads_utm attr_events attr_staff | bookings, advisors, funnel, marketing source — no names/emails/phones, and no deal amounts |
| same dashboard, reporting revenue | above + attr_activity_fin attr_leads_fin | adds the amounts, still with no identity fields |
| app that needs contacts | above + crm_leads_pii | adds contact/identity fields, for that key only |
Pagination & filtering
| Param | Type | Description |
|---|---|---|
| limit | integer | Rows to return. Default 100, max 1000 |
| offset | integer | Rows to skip. Default 0. Fine for the first few pages; see after for large pulls |
| after | cursor | Resume from the previous page's next_after. Stays fast at any depth. Can't be combined with offset |
| {column}={value} | any | Any real column becomes an equality filter (else 400) |
| {column}__gte / __lte | any | Range: >= / <=. Combine both for a closed interval |
| {column}__gt / __lt | any | Range, exclusive: > / < |
| {column}__in | any | Set 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 }. Single-record routes (/v1/{dataset}/{id}) return the row object directly.
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
| Limit | Value | Notes |
|---|---|---|
| rate limit | none | No request throttling. Be reasonable — it's a shared warehouse, and a runaway loop is felt by other apps. |
| rows per request | 1000 | limit above this is silently capped, not rejected. Default 100. |
| deep offsets | 504 | The 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:
| Feeds | Cadence |
|---|---|
| Close activities & leads sync, Stripe and Whop pollers | continuous |
| Staff / user directory | every 15 min |
| Attribution pipeline and the nightly CRM sync | daily 02:00 |
| Extended CRM sync | Tuesdays 02:45 |
| Marketing / UTM source data | Sundays 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:
| Metric | Notes |
|---|---|
| count | rows in the group — the default if you pass no metrics |
| count_distinct:column | unique values. The most expensive metric by far — always pair it with a filter |
| sum:column · avg:column | numeric columns only |
| min:column · max:column | any 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:
| Rule | Catches |
|---|---|
any @egelloc.com address, primary or secondary | staff and most QA bookings |
display name starting ZZQA | the QA naming convention |
test@test.com | generic 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.