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.

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.comAPI 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.

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>
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 }. 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

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 bucketgroup_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.