Playground Data API
A read-only HTTP API over the onboarding app's own database. Authenticate with a scoped bearer key and pull JSON — no database connection string required. Every dataset is a curated, allowlisted view; student and parent identity lives only behind dedicated *_pii scopes, and amounts only behind *_fin.
Live, not a copy
These views read the onboarding app's tables directly. There is no warehouse copy, no sync job and no lag — what the app committed a second ago is what you get. 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; amounts require a *_fin one. The two are independent. These are minors' records, so the academic and psychometric fields are behind no scope at all.
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 Playground 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. pg_enrollments, pg_student_crosswalk).
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 — student and parent identity (names, emails, phones) lives only in the separate
*_piidatasets. A key without a*_piiscope never sees it — and the non-PII view doesn't even contain those columns. - Money-gated — amounts live only in the
*_findatasets (the Money group).pg_enrollmentscarries nototal_centsand no refund amount. What stays is the shape of the deal — plan type, how many instalments, how many have been charged, whether the plan is still running, the service window, and whether it was refunded, disputed or cancelled. That is what operational and funnel work needs and it reveals no figure. Joinpg_enrollments_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 — pg_nest_sync is not marked _pii, but its last_error is whatever the Nest returned, and a rejection can quote the student record it rejected. Treat that dataset as possibly carrying student data.
So an app that doesn't need PII is granted only the plain scopes and cannot retrieve it. Example — the accountants' reporting path:
| App | Granted scopes | Can read |
|---|---|---|
| ops / funnel view (no PII, no money) | pg_enrollments pg_student_crosswalk pg_programs pg_journey | what was sold, to which student id, over what service window, and whether it was reversed — no names/emails, no amounts |
| accountants, reporting revenue | above + pg_enrollments_fin pg_program_plans_fin | adds the amounts, still with no identity fields |
| a path that emails families | above + pg_student_crosswalk_pii | adds student and parent names and emails, for that key only |
| Nest sync monitoring | pg_nest_sync pg_stripe_events | queue health and webhook receipts — nothing about what was sold |
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: ?status__in=paid,completed. 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: ?paid_at__gte=2026-07-01&paid_at__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 — ?status__in=paid,onboarding_started,completed 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 always use a half-open interval — ?paid_at__gte=2026-07-01&paid_at__lt=2026-08-01. Every timestamp on these datasets is timestamp without time zone holding UTC, so no offset is applied to what you pass.
Every dataset here fits in one page today. This is a young database — single-digit enrollments, tens of students — so paging and range filters are about correctness, not cost: build on them now and nothing changes as the data grows. 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 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. 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, so a row you need to be current will be.
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 here reports stable_order: true, and every sort key is backed by a real unique index on the underlying table rather than being unique only in today's data. The two joined views earn that deliberately: pg_enrollments reaches student_details through a LATERAL … LIMIT 1, and pg_student_crosswalk collapses a student's enrollments in a CTE, so neither can emit two rows for one key and break a cursor walk. One exception to note on identity: pg_stripe_events sorts on event_id, Stripe's own id, because that table has no separate id column.
Pulling a whole dataset — use the cursor
offset makes the database walk and throw away every row it skips, so it collapses with depth. Nothing here is deep enough for that to bite yet — but the sibling Attribution API measured offset=700000 at 245 s against ~115 ms for the equivalent cursor page, and this is the same code. Pass after and the index turns the page into a seek: 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/pg_enrollments?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 without exception, because every one has a unique sort key.
total is null on cursor pages. Counting the rows is the slowest part of a request on any sizeable table 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 'pg_enrollments_fin'" }
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 — these views read the live onboarding database, and a runaway loop competes with families completing enrollment. |
| 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. No dataset here is deep enough to hit it today; use after anyway and it never will be. |
How fresh is the data? — there is no sync
This is the part that differs most from the Nest and Attribution APIs, and it is the reason this service exists in the shape it does. Those two read a warehouse copy, so their answers are as current as the last sync job. This one reads the onboarding application's own tables, through views. There is no copy, no sync job, no queue and no lag: a payment that committed a second ago is readable now.
What that buys you. Nothing can drift. There is no second version of a revenue figure to reconcile against the app, no stuck-queue backlog to monitor, and no window in which a refund has happened but the reported data does not know it. The row's own updated_at is the authoritative timestamp, and it is the app's.
What it costs you. Reads compete with live traffic — see the rate-limit note under Limits — and you cannot join across to the Attribution API in SQL, because that data is on a different database cluster and Postgres cannot join across clusters. Pull both and join on close_lead_id in your own code. Volumes here make that cheap.
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://playground-data-api.egelloc.com/v1/pg_enrollments/aggregate?group_by=program_name,status&metrics=count&paid_at__gte=2026-07-01&paid_at__lt=2026-08-01"
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=paid_at:month — 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 paid_at_month, 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 here is properly typed — and it is in cents. Unlike the Attribution API, where amounts extracted from CRM notes are stored as text, every amount on these datasets is an integer number of cents. So sum:total_cents on pg_enrollments_fin works directly — and returns cents. Divide by 100 for dollars at the point you display it, never before you add up.
Filter it where you can. The same filters as the list endpoint apply, and they matter more here: an aggregate reads every matching row. Nothing here is large enough to time out today, but the statement timeout is real and a date range is the usual fix if it ever bites. 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.
Nothing is filtered out — and today most of it is test data
The Attribution API strips internal test and staff leads server-side so its figures tie out with the dashboards. This API filters nothing. Every view returns every row the app holds, because on a live operational database the alternative — a hidden exclusion rule — is worse: it makes a missing enrollment indistinguishable from an excluded one, which is exactly the question people come to this data to answer.
So read the current contents carefully. As of 10 September 2026 the onboarding database holds 9 enrollments, 40 users and 17 Stripe webhook events, and they are predominantly test records created while the app was being built, alongside staff accounts. Do not report these as revenue. Three of the nine enrollments are expired and three carry payment_status = none.
One field to know about before you build a join. close_lead_id — the key to the Attribution API — is NULL on every enrollment row at the time of writing. The column shipped with the app's Phase 1 work but nothing populates it yet. Any report that joins onboarding data to Close attribution will return nothing until that is fixed, and it will do so silently rather than erroring. Check for non-null coverage before you trust such a join.
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, quote the row's updated_at and the dataset name; because there is no sync layer, a wrong value here is a wrong value in the app itself, which is worth saying out loud when you report it.
Health & docs
curl -s /v1/health → {"status":"ok"} curl -s /v1/health/db → {"status":"ok","db":"ok"}
Interactive OpenAPI docs: /docs · /redoc · /openapi.json.