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
POST/admin/projects/:projectId/usersAdmin-create an app user (seed / migration attribution).
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/cohortMint N anonymous users sharing properties (and optional segments).
POST/admin/projects/:projectId/users/:userId/entitlementsGrant or refresh a user's entitlement (Stripe / Paddle / manual).
DELETE/admin/projects/:projectId/users/:userId/resetReset one user's state to zero (optionally also delete the user).

POST /admin/projects/:projectId/users

Admin-create an app user from the developer / agent side. Use this to mint a system / seed user to attribute migrated rows to — for example after resetting a sandbox. All fields are optional; with no fields it mints an anonymous user (a random anonymous_id is generated so the row is a valid, usable anonymous user rather than an identity-less orphan).

Request

FieldTypeRequiredDescription
external_idstringnoYour stable external identifier (unique).
anonymous_idstringnoAnonymous device id (unique). Auto-generated if no identity given.
emailstringnoEmail address.
phonestringnoPhone number.
display_namestringnoDisplay name.
avatar_urlstringnoAvatar URL.
propertiesobjectnoArbitrary user properties.

Response 201

The created user row (id, email, phone, display_name, avatar_url, external_id, anonymous_id, auth_providers, properties, timestamps).

Errors

  • 400 INVALID_BODY — body isn't a JSON object, or properties isn't an object.
  • 409 USER_ALREADY_EXISTS — a user with the same external_id, anonymous_id, or email already exists.
  • 500 CREATE_FAILED.

Over MCP this is amba_users_create.

Curl:

curl -X POST 'https://api.amba.dev/v1/admin/projects/$PROJECT_ID/users' \
  -H 'Authorization: Bearer $AMBA_PAT' \
  -H 'Content-Type: application/json' \
  -d '{ "external_id": "seed_system", "display_name": "Migration bot" }'

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

POST /admin/projects/:projectId/users/cohort

Mint N anonymous users in one call, all stamped with the same properties (and optionally enrolled into the same segments). The fast path for seeding a test cohort, populating a leaderboard, or dry-running a push segment — POST /users mints one user, bulk-update mutates existing ones, this fills the "create 50 users tagged cohort: beta" gap. Each minted user is anonymous (a random anonymous_id), since a shared-properties cohort has no per-user external identity.

Request

FieldTypeRequiredDescription
countintegeryesHow many users to mint. 1–500.
propertiesobjectnoStamped identically onto every minted user.
segment_idsuuid[]noSegment UUIDs to enrol every minted user into.

Response 201

{ "data": { "user_ids": ["…", "…"], "created_count": 50 } }

Each minted user fires a user.created event. A user.created subscriber sees cohort: true in the event data.

Errors

  • 400 INVALID_BODY — body isn't a JSON object, or properties / segment_ids have the wrong shape.
  • 400 INVALID_COUNTcount is not an integer in 1–500.
  • 400 INVALID_BODY — a segment_id references a segment that doesn't exist.
  • 500 COHORT_FAILED.

Try it:

POST/admin/projects/%7B%7BprojectId%7D%7D/users/cohort
developer auth
curl -X POST 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/users/cohort' \
  -H 'Content-Type: application/json' \
  -d '{
  "count": 25,
  "properties": {
    "cohort": "beta"
  }
}'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

curl -X POST '${BASE_URL}/admin/projects/{projectId}/users/cohort' \
  -H 'Authorization: Bearer ${DEV_TOKEN}' \
  -H 'Content-Type: application/json' \
  -d '{ "count": 50, "properties": { "cohort": "beta" }, "segment_ids": ["…"] }'

DELETE /admin/projects/:projectId/users/:userId/reset

Reset one user back to a brand-new state — clear their gamification, economy, engagement, participation, and session state — without deleting the user. Their identity (email, anonymous_id, properties) survives. This is the per-user analogue of the all-or-nothing sandbox reset, so resetting a probe user doesn't nuke a deliberately-seeded cohort.

Developer-authored configuration (currency / achievement / catalog definitions and other project config) is never touched — a reset returns the user to zero, it doesn't reconfigure the project. The whole sweep runs in one transaction: a mid-sweep failure leaves the user's state intact.

Query

ParamTypeDefaultNotes
deletebooleanfalse?delete=true also removes the app_users row at the end.

Response 200

{
  "data": {
    "user_id": "…",
    "user_deleted": false,
    "tables_cleared": { "user_xp": 1, "engagement_events": 42, "user_balances": 3 },
    "total_rows_cleared": 46
  }
}

tables_cleared maps each touched table to the number of rows removed (tables with nothing to clear are omitted). A reset emits user.reset; a ?delete=true reset emits user.deleted instead.

Errors

  • 400 INVALID_USER_IDuserId is not a valid UUID.
  • 404 — no such user (an already-deleted id).
  • 500 RESET_FAILED.

Try it:

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

Curl:

# Reset state, keep the user
curl -X DELETE '${BASE_URL}/admin/projects/{projectId}/users/{userId}/reset' \
  -H 'Authorization: Bearer ${DEV_TOKEN}'
 
# Reset, then delete the user row
curl -X DELETE '${BASE_URL}/admin/projects/{projectId}/users/{userId}/reset?delete=true' \
  -H 'Authorization: Bearer ${DEV_TOKEN}'