Amba

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

MethodPathDescription
GET/admin/projects/:projectId/usersList / search users, optionally filtered by segment.
GET/admin/projects/:projectId/users/:userIdFetch one user with streaks + entitlements.
GET/admin/projects/:projectId/users/:userId/eventsPaginated engagement-event history for a user.
GET/admin/projects/:projectId/users/exportStream all users as CSV/NDJSON.
GET/admin/projects/:projectId/users/:userId/events/exportStream a single user's events as CSV/NDJSON.
GET/admin/projects/:projectId/users/:userId/exportStream a full per-user GDPR bundle (NDJSON) across every tenant table.
POST/admin/projects/:projectId/users/bulk-updateApply property + segment updates to up to 1000 users.
POST/admin/projects/:projectId/users/:userId/entitlementsGrant 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

ParamTypeDefaultDescription
limitint50Page size.
offsetint0Offset.
searchstringILIKE match on email / external_id / display_name.
segment_iduuidOnly users in this segment.

Response 200

{
  "data": [ { "id": "…", "email": "…", "display_name": "…", "external_id": "…", "last_seen_at": "…",  } ],
  "total": 1234,
  "offset": 0,
  "limit": 50
}

Errors

  • 500 LIST_FAILED.

Try it:

GET/admin/projects/%7B%7BprojectId%7D%7D/users
developer auth
curl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

curl -X GET '${BASE_URL}/admin/projects/{projectId}/users' \
  -H 'Authorization: Bearer ${DEV_TOKEN}'

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

{
  "data": {
    "id": "…",
    "email": "…",
    "display_name": "…",
    "anonymous_id": "…",
    "auth_providers": [{ "provider": "apple", "provider_id": "…" }],
    "properties": {},
    "first_seen_at": "…",
    "last_seen_at": "…",
    "streaks": [
      {
        "id": "…",
        "app_user_id": "…",
        "streak_definition_id": "…",
        "current_count": 7,
        "longest_count": 42,
        "status": "active",
        "streak_definitions": { "name": "Daily check-in", "qualifying_event": "check_in" }
      }
    ],
    "entitlements": [
      { "id": "…", "entitlement_id": "pro", "is_active": true, "expiration_date": "…" }
    ]
  }
}

Errors

  • 404 NOT_FOUND — user does not exist.
  • 500 FETCH_FAILED.

Try it:

GET/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D
developer auth
curl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

curl -X GET '${BASE_URL}/admin/projects/{projectId}/users/{userId}' \
  -H 'Authorization: Bearer ${DEV_TOKEN}'

GET /admin/projects/:projectId/users/:userId/events

Paginated event history for a user, most recent first.

Query

ParamDefaultDescription
limit50Page size.
offset0Offset.

Response 200

{
  "data": [
    {
      "id": "…",
      "app_user_id": "…",
      "event_name": "workout_completed",
      "properties": {},
      "occurred_at": "…"
    }
  ]
}

Errors

  • 500 LIST_FAILED.

Try it:

GET/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/events
developer auth
curl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/events'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

curl -X GET '${BASE_URL}/admin/projects/{projectId}/users/{userId}/events' \
  -H 'Authorization: Bearer ${DEV_TOKEN}'

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

ParamTypeDefaultDescription
formatcsv | ndjsoncsvOutput format.
sinceISO-8601Optional 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.

id,email,anonymous_id,created_at,last_seen_at,properties,platform_strings,push_token_count
…,user@example.com,…,2026-04-01T12:00:00.000Z,2026-04-23T08:30:00.000Z,{"plan":"pro"},ios|android,2

Errors

  • 400 INVALID_SINCEsince is not a parseable ISO-8601 timestamp.
  • 502 TENANT_UNAVAILABLE — your database validation failed before the stream opened.

Try it:

GET/admin/projects/%7B%7BprojectId%7D%7D/users/export
developer auth
curl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users/export'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

curl -X GET '${BASE_URL}/admin/projects/{projectId}/users/export' \
  -H 'Authorization: Bearer ${DEV_TOKEN}'

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

ParamTypeDefaultDescription
formatcsv | ndjsoncsvOutput format.
sinceISO-8601Lower bound on occurred_at.
untilISO-8601Upper bound on occurred_at.
event_namestringFilter 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_IDuserId is not a UUID.
  • 404 NOT_FOUND — user does not exist.
  • 500 FETCH_FAILED.

Try it:

GET/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/events/export
developer auth
curl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/events/export'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

curl -X GET '${BASE_URL}/admin/projects/{projectId}/users/{userId}/events/export' \
  -H 'Authorization: Bearer ${DEV_TOKEN}'

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:

{ "section": "<table-name>", "data": { ... } }

Content-Type: application/x-ndjson; charset=utf-8. Content-Disposition: attachment; filename="user-<userId>-export.ndjson".

Sections (in emit order)

SectionSource tableNotes
app_usersapp_usersThe profile row. Public columns only — password_hash is omitted.
account_linksaccount_linksAudit trail of anonymous → authenticated transitions.
push_tokenspush_tokensDevice tokens registered for push.
user_entitlementsuser_entitlementsSubscription / paid-grant rows.
segment_membershipssegment_membershipsSegments this user belongs to.
user_streaksuser_streaksOne row per streak_definition the user has interacted with.
streak_eventsstreak_eventsPer-event audit log of streak state transitions.
user_achievementsuser_achievementsUnlocked + in-progress achievements.
leaderboard_entriesleaderboard_entriesThe user's rank rows across all leaderboards.
engagement_eventsengagement_eventsThe 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:

{
  "section": "engagement_events",
  "error": "SECTION_QUERY_FAILED",
  "message": "this section could not be fully exported; rows above this marker are valid, the rest of the bundle is intact"
}

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

{"section":"app_users","data":{"id":"…","email":"alice@example.com","display_name":"OakHiker","anonymous_id":"anon_…","auth_providers":[{"provider":"apple","provider_id":"…"}],"properties":{},"first_seen_at":"…","last_seen_at":"…","created_at":"…","updated_at":"…"}}
{"section":"account_links","data":{"id":"…","app_user_id":"…","provider":"apple","provider_id":"…","linked_at":"…"}}
{"section":"push_tokens","data":{"id":"…","app_user_id":"…","platform":"ios","token":"…","is_active":true,"created_at":"…"}}
{"section":"user_entitlements","data":{"id":"…","app_user_id":"…","entitlement_id":"premium","is_active":true,"store":"app_store","product_id":"…","purchase_date":"…","expiration_date":"…","period_type":"normal","raw_data":{},"updated_at":"…"}}
{"section":"engagement_events","data":{"id":"…","app_user_id":"…","event_name":"workout_completed","properties":{},"occurred_at":"…"}}

Errors

  • 400 INVALID_USER_IDuserId is 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:

GET/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/export
developer auth
curl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/export'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

curl -X GET '${BASE_URL}/admin/projects/{projectId}/users/{userId}/export' \
  -H 'Authorization: Bearer ${DEV_TOKEN}' \
  -o "user-{userId}-export.ndjson"

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

FieldTypeRequiredDescription
user_idsstring[]yesApp-user UUIDs (1-1000).
updates.propertiesobjectconditionalTop-level keys merged into each user's properties.
updates.add_segment_idsstring[]conditionalSegment UUIDs to add (idempotent).
updates.remove_segment_idsstring[]conditionalSegment UUIDs to remove.

At least one of properties, add_segment_ids, remove_segment_ids must be present.

Response 200

{
  "data": {
    "updated": 998,
    "failed": [{ "user_id": "…", "reason": "NOT_FOUND" }]
  }
}

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_IDSuser_ids empty or absent.
  • 400 INVALID_USER_IDSuser_ids contains non-strings.
  • 400 BATCH_TOO_LARGE — more than 1000 user_ids; error.details includes limit and supplied.
  • 400 EMPTY_UPDATES — none of properties, add_segment_ids, remove_segment_ids set.
  • 400 INVALID_UUID — the database rejected one of the supplied IDs as a malformed UUID.
  • 500 BULK_UPDATE_FAILED.

Try it:

POST/admin/projects/%7B%7BprojectId%7D%7D/users/bulk-update
developer auth
curl -X POST 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users/bulk-update'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

curl -X POST '${BASE_URL}/admin/projects/{projectId}/users/bulk-update' \
  -H 'Authorization: Bearer ${DEV_TOKEN}' \
  -H 'Content-Type: application/json' \
  -d '{}'

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 behaviourEffect on existing row
Field omitted from request bodyPreserve the existing column value.
Field supplied as nullClear the column to NULL.
Field supplied as a valueSet 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

FieldTypeRequiredDescription
entitlement_idstringyesLogical identifier (e.g. "premium"). Non-empty.
is_activebooleannoDefaults to true on a fresh grant.
product_idstring | nullnoStore product identifier. null clears.
storestring | nullnoOne of app_store, play_store, stripe. null clears.
purchase_datestring | nullnoISO-8601 timestamp. null clears.
expiration_datestring | nullnoISO-8601 timestamp. null clears (treated as lifetime).
period_typestring | nullnoOne of trial, intro, normal. null clears.
raw_dataobject | nullnoOpaque JSON for audit (the source receipt, the support ticket id, etc). null clears to {}.

Response 200

{
  "data": {
    "id": "…",
    "entitlement_id": "premium",
    "is_active": true,
    "store": "stripe",
    "product_id": "stripe_premium_yearly",
    "purchase_date": "2026-04-01T12:00:00.000Z",
    "expiration_date": "2027-04-01T12:00:00.000Z",
    "period_type": "normal",
    "updated_at": "2026-05-04T08:30:00.000Z"
  }
}

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_IDentitlement_id is missing or empty.
  • 400 INVALID_PRODUCT_IDproduct_id is not a string or null.
  • 400 INVALID_STOREstore is not one of app_store, play_store, stripe (or null).
  • 400 INVALID_PERIOD_TYPEperiod_type is not one of trial, intro, normal (or null).
  • 400 INVALID_PURCHASE_DATEpurchase_date is not parseable ISO-8601.
  • 400 INVALID_EXPIRATION_DATEexpiration_date is not parseable ISO-8601.
  • 400 INVALID_RAW_DATAraw_data is not an object or null (arrays / scalars rejected).
  • 400 INVALID_USER_IDuserId is not a valid UUID.
  • 404 USER_NOT_FOUND — no app_user with the given userId in this project.
  • 500 GRANT_FAILED.

Try it:

POST/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/entitlements
developer auth
curl -X POST 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D/entitlements'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl — fresh Stripe grant:

curl -X POST '${BASE_URL}/admin/projects/{projectId}/users/{userId}/entitlements' \
  -H 'Authorization: Bearer ${DEV_TOKEN}' \
  -H 'Content-Type: application/json' \
  -d '{
    "entitlement_id": "premium",
    "is_active": true,
    "store": "stripe",
    "product_id": "stripe_premium_yearly",
    "purchase_date": "2026-04-01T12:00:00Z",
    "expiration_date": "2027-04-01T12:00:00Z",
    "period_type": "normal",
    "raw_data": { "stripe_subscription_id": "sub_…" }
  }'

Curl — revoke (e.g. ToS violation):

curl -X POST '${BASE_URL}/admin/projects/{projectId}/users/{userId}/entitlements' \
  -H 'Authorization: Bearer ${DEV_TOKEN}' \
  -H 'Content-Type: application/json' \
  -d '{ "entitlement_id": "premium", "is_active": false }'

Curl — clear an expiration_date (extend to lifetime):

curl -X POST '${BASE_URL}/admin/projects/{projectId}/users/{userId}/entitlements' \
  -H 'Authorization: Bearer ${DEV_TOKEN}' \
  -H 'Content-Type: application/json' \
  -d '{ "entitlement_id": "premium", "expiration_date": null }'