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.

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 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 _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. pg_enrollments, pg_student_crosswalk).

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 — student and parent identity (names, emails, phones) lives only in the separate *_pii datasets. A key without a *_pii scope never sees it — and the non-PII view doesn't even contain those columns.
  • Money-gated — amounts live only in the *_fin datasets (the Money group). pg_enrollments carries no total_cents and 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. Join pg_enrollments_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 — 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:

AppGranted scopesCan read
ops / funnel view (no PII, no money)pg_enrollments pg_student_crosswalk pg_programs pg_journeywhat was sold, to which student id, over what service window, and whether it was reversed — no names/emails, no amounts
accountants, reporting revenueabove + pg_enrollments_fin pg_program_plans_finadds the amounts, still with no identity fields
a path that emails familiesabove + pg_student_crosswalk_piiadds student and parent names and emails, for that key only
Nest sync monitoringpg_nest_sync pg_stripe_eventsqueue health and webhook receipts — nothing about what was sold

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: ?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

LimitValueNotes
rate limitnoneNo request throttling. Be reasonable — these views read the live onboarding database, and a runaway loop competes with families completing enrollment.
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. 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 bucketgroup_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:

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