Users
List and inspect end users in your project, including their streaks, entitlements, and event history.
These endpoints support searching users, filtering by segment, and drilling into a single user's gamification + entitlement + event state.
Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /admin/projects/:projectId/users | List / search users, optionally filtered by segment. |
| GET | /admin/projects/:projectId/users/:userId | Fetch one user with streaks + entitlements. |
| GET | /admin/projects/:projectId/users/:userId/events | Paginated engagement-event history for a user. |
| GET | /admin/projects/:projectId/users/export | Stream all users as CSV/NDJSON. |
| GET | /admin/projects/:projectId/users/:userId/events/export | Stream a single user's events as CSV/NDJSON. |
| GET | /admin/projects/:projectId/users/:userId/export | Stream a full per-user GDPR bundle (NDJSON) across every tenant table. |
| POST | /admin/projects/:projectId/users/bulk-update | Apply property + segment updates to up to 1000 users. |
| POST | /admin/projects/:projectId/users/:userId/entitlements | Grant or refresh a user's entitlement (Stripe / Paddle / manual). |
GET /admin/projects/:projectId/users
List users, newest-active first. Supports limit, offset, optional search across email / external_id / display_name (ILIKE, metacharacters escaped), and optional segment_id to filter to members of a segment.
Query
| Param | Type | Default | Description |
|---|---|---|---|
limit | int | 50 | Page size. |
offset | int | 0 | Offset. |
search | string | — | ILIKE match on email / external_id / display_name. |
segment_id | uuid | — | Only users in this segment. |
Response 200
Errors
500 LIST_FAILED.
Try it:
/admin/projects/%7B%7BprojectId%7D%7D/userscurl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users'Curl:
GET /admin/projects/:projectId/users/:userId
Fetch a single user with their current streaks (each inlined with its streak-definition name and qualifying_event) and entitlements.
Response 200
Errors
404 NOT_FOUND— user does not exist.500 FETCH_FAILED.
Try it:
/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7Dcurl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D'Curl:
GET /admin/projects/:projectId/users/:userId/events
Paginated event history for a user, most recent first.
Query
| Param | Default | Description |
|---|---|---|
limit | 50 | Page size. |
offset | 0 | Offset. |
Response 200
Errors
500 LIST_FAILED.
Try it:
/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/eventscurl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/events'Curl:
GET /admin/projects/:projectId/users/export
Stream every user in the your database as CSV (default) or NDJSON. Streamed via a streaming cursor with batch size 500 — no full result set in memory. Each row includes id, email, anonymous_id, created_at, last_seen_at, properties, platform_strings (pipe-joined for CSV), and push_token_count.
Query
| Param | Type | Default | Description |
|---|---|---|---|
format | csv | ndjson | csv | Output format. |
since | ISO-8601 | — | Optional lower bound on created_at. |
Response 200
Content-Type: text/csv; charset=utf-8 or application/x-ndjson; charset=utf-8. CSV includes a header row; NDJSON emits one JSON object per line.
Errors
400 INVALID_SINCE—sinceis not a parseable ISO-8601 timestamp.502 TENANT_UNAVAILABLE— your database validation failed before the stream opened.
Try it:
/admin/projects/%7B%7BprojectId%7D%7D/users/exportcurl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users/export'Curl:
GET /admin/projects/:projectId/users/:userId/events/export
Stream a single user's engagement events as CSV (default) or NDJSON. The user's existence is verified before the response stream commits to a 200 — a missing user returns the standard JSON error envelope.
Query
| Param | Type | Default | Description |
|---|---|---|---|
format | csv | ndjson | csv | Output format. |
since | ISO-8601 | — | Lower bound on occurred_at. |
until | ISO-8601 | — | Upper bound on occurred_at. |
event_name | string | — | Filter to a specific event_name. |
Response 200
Content-Type: text/csv; charset=utf-8 or application/x-ndjson; charset=utf-8. Columns: id, app_user_id, event_name, properties, occurred_at.
Errors
400 INVALID_SINCE/INVALID_UNTIL— unparseable timestamps.400 INVALID_USER_ID—userIdis not a UUID.404 NOT_FOUND— user does not exist.500 FETCH_FAILED.
Try it:
/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/events/exportcurl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/events/export'Curl:
GET /admin/projects/:projectId/users/:userId/export
Stream a single user's full data bundle as NDJSON. This is the canonical "GDPR right-to-access" export — every tenant table that holds rows keyed on this user contributes data. Streamed via streaming cursor (batch size 500) so a power user with millions of events does not OOM the API.
The user's existence is verified before the response stream commits to a 200, so a missing user returns the standard JSON error envelope rather than a truncated stream.
Output format
NDJSON only (no CSV variant — the bundle is sectioned and CSV cannot model nested sections cleanly). Each line is a JSON object of shape:
Content-Type: application/x-ndjson; charset=utf-8. Content-Disposition: attachment; filename="user-<userId>-export.ndjson".
Sections (in emit order)
| Section | Source table | Notes |
|---|---|---|
app_users | app_users | The profile row. Public columns only — password_hash is omitted. |
account_links | account_links | Audit trail of anonymous → authenticated transitions. |
push_tokens | push_tokens | Device tokens registered for push. |
user_entitlements | user_entitlements | Subscription / paid-grant rows. |
segment_memberships | segment_memberships | Segments this user belongs to. |
user_streaks | user_streaks | One row per streak_definition the user has interacted with. |
streak_events | streak_events | Per-event audit log of streak state transitions. |
user_achievements | user_achievements | Unlocked + in-progress achievements. |
leaderboard_entries | leaderboard_entries | The user's rank rows across all leaderboards. |
engagement_events | engagement_events | The full event firehose for this user. Last (potentially largest). |
Empty sections contribute zero lines — there is no header marker. Consumers detect "no data in section X" by absence.
Partial-export markers
If a single section's cursor errors mid-stream (e.g. a transient DB blip), the handler does not abort the bundle. It logs server-side and emits one marker line:
Rows already streamed before the marker are valid; the bundle continues with the next section. Treat the marker as "this section is partial up to this line".
Response 200
Sample (truncated for clarity):
Errors
400 INVALID_USER_ID—userIdis not a valid UUID.404 NOT_FOUND— user does not exist.500 EXPORT_FAILED— pre-stream lookup failed (DB unreachable, etc).502 TENANT_UNAVAILABLE— your database resolution failed before the stream opened.
Try it:
/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/exportcurl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/export'Curl:
POST /admin/projects/:projectId/users/bulk-update
Apply property merges and segment add/remove to up to 1000 users in a single transactional call. Properties are merged with jsonb concatenation (top-level merge, mirrors track() semantics). Segment add uses ON CONFLICT DO NOTHING, so it is idempotent. The whole request runs in one transaction — partial failures cannot leave a user with merged properties but missing segment tags.
Request
| Field | Type | Required | Description |
|---|---|---|---|
user_ids | string[] | yes | App-user UUIDs (1-1000). |
updates.properties | object | conditional | Top-level keys merged into each user's properties. |
updates.add_segment_ids | string[] | conditional | Segment UUIDs to add (idempotent). |
updates.remove_segment_ids | string[] | conditional | Segment UUIDs to remove. |
At least one of properties, add_segment_ids, remove_segment_ids must be present.
Response 200
failed lists user_ids that did not exist in the your database; nothing is updated for those rows. The transaction still commits — non-existent users do not block the rest.
Errors
400 INVALID_BODY— body is not JSON.400 MISSING_USER_IDS—user_idsempty or absent.400 INVALID_USER_IDS—user_idscontains non-strings.400 BATCH_TOO_LARGE— more than 1000 user_ids;error.detailsincludeslimitandsupplied.400 EMPTY_UPDATES— none ofproperties,add_segment_ids,remove_segment_idsset.400 INVALID_UUID— the database rejected one of the supplied IDs as a malformed UUID.500 BULK_UPDATE_FAILED.
Try it:
/admin/projects/%7B%7BprojectId%7D%7D/users/bulk-updatecurl -X POST 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users/bulk-update'Curl:
POST /admin/projects/:projectId/users/:userId/entitlements
Grant or refresh a user's entitlement directly from a server-side caller. This is the path to use for any flow that does not come through a RevenueCat or Superwall webhook — Stripe / Paddle web checkout, manual support grants, gift codes, ToS-violation revokes, and migrations from legacy systems.
For mobile in-app purchases, prefer the RevenueCat webhook — it is push-only and remains the source of truth for App Store / Play Store subscriptions.
Upserts on (user, entitlement_id), so calling this endpoint a second time for the same pair refreshes the existing entitlement in place — matching the RevenueCat webhook's semantics.
Update semantics
For every nullable column, the handler tracks both the value and whether the caller actually supplied the field. This lets the upsert distinguish three cases on refresh:
| Caller behaviour | Effect on existing row |
|---|---|
| Field omitted from request body | Preserve the existing column value. |
Field supplied as null | Clear the column to NULL. |
| Field supplied as a value | Set the column to that value. |
is_active is always written from the body (defaulting to true when omitted on a fresh grant). raw_data is NOT NULL DEFAULT '{}' at the schema level, so passing null resets it to {} rather than SQL NULL.
Request
| Field | Type | Required | Description |
|---|---|---|---|
entitlement_id | string | yes | Logical identifier (e.g. "premium"). Non-empty. |
is_active | boolean | no | Defaults to true on a fresh grant. |
product_id | string | null | no | Store product identifier. null clears. |
store | string | null | no | One of app_store, play_store, stripe. null clears. |
purchase_date | string | null | no | ISO-8601 timestamp. null clears. |
expiration_date | string | null | no | ISO-8601 timestamp. null clears (treated as lifetime). |
period_type | string | null | no | One of trial, intro, normal. null clears. |
raw_data | object | null | no | Opaque JSON for audit (the source receipt, the support ticket id, etc). null clears to {}. |
Response 200
The status is always 200, including the first-grant case (the upsert returns the inserted or updated row uniformly).
Errors
400 INVALID_BODY— body is not JSON.400 INVALID_ENTITLEMENT_ID—entitlement_idis missing or empty.400 INVALID_PRODUCT_ID—product_idis not a string or null.400 INVALID_STORE—storeis not one ofapp_store,play_store,stripe(or null).400 INVALID_PERIOD_TYPE—period_typeis not one oftrial,intro,normal(or null).400 INVALID_PURCHASE_DATE—purchase_dateis not parseable ISO-8601.400 INVALID_EXPIRATION_DATE—expiration_dateis not parseable ISO-8601.400 INVALID_RAW_DATA—raw_datais not an object or null (arrays / scalars rejected).400 INVALID_USER_ID—userIdis not a valid UUID.404 USER_NOT_FOUND— noapp_userwith the givenuserIdin this project.500 GRANT_FAILED.
Try it:
/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/entitlementscurl -X POST 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/entitlements'Curl — fresh Stripe grant:
Curl — revoke (e.g. ToS violation):
Curl — clear an expiration_date (extend to lifetime):