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 one user's GDPR data bundle (NDJSON): profile, records, events.
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).
DELETE/admin/projects/:projectId/users/:userIdDelete one user and every row that belongs to them.
DELETE/admin/projects/:projectId/users?anonymous_id=…Delete one user by anonymous_id.

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 ${DEV_TOKEN}" \
  -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.
searchstring—ILIKE match on email / external_id / display_name.
segment_iduuid—Only 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/${PROJECT_ID}/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/${PROJECT_ID}/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/${PROJECT_ID}/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-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.

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_SINCE — since 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/${PROJECT_ID}/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-8601—Lower bound on occurred_at.
untilISO-8601—Upper bound on occurred_at.
event_namestring—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 — userId 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/${PROJECT_ID}/users/{userId}/events/export" \
  -H "Authorization: Bearer ${DEV_TOKEN}"

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

Stream a single user's data bundle as NDJSON. This is the canonical "GDPR right-to-access" export: the user's profile, one section per kind of record the project keeps about the user (listed under Sections), and a closing export_complete line. Every section streams through a database cursor in batches of 500 rows with no row cap, so a power user with millions of events does not exhaust the API's memory.

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": { ... } }

Lines of the collections section add a collection key with the collection's name. The last line is the closing line.

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

Sections (in emit order)

Each section is named after the table its rows come from. Each line carries the stored row with every column, except the credential columns listed under Left out.

SectionThe user's rows
app_usersThe profile, one line: every column (email, phone, timezone, email_verified, phone_verified, …) except password_hash.
account_linksAnonymous ids linked to the account.
push_tokensDevice tokens registered for push.
user_entitlementsSubscription and paid-grant rows.
segment_membershipsSegments the user belongs to.
user_streaksOne row per streak the user has started.
streak_eventsStreak state transitions, oldest first.
user_achievementsUnlocked and in-progress achievements.
leaderboard_entriesRank rows across leaderboards.
app_sessionsApp sessions the SDK reported: platform, app version, device model, IP address.
app_user_sessionsSign-in sessions: IP address, user agent, expiry, revocation.
magic_link_tokensMagic links issued to the user, plus links requested for the user's email address before the account existed.
email_otp_challengesOne-time email codes requested for the user's email address.
sms_otp_challengesOne-time SMS codes requested for the user's phone number.
sms_outbound_messagesTexts sent to the user's phone number, with their delivery status.
user_xpXP total and level.
xp_ledgerEvery XP grant and deduction.
user_challengesChallenge progress.
league_membershipsLeague cohorts the user was placed in.
user_balancesCurrency balances.
currency_transactionsEvery currency movement.
user_inventoryItems the user holds.
currency_grant_ledgerCurrency the project's grant rules gave the user.
entitlement_cascade_ledgerRewards that fired when the user was granted an entitlement.
agent_checkoutsAgent-initiated checkouts made for the user.
user_onboardingOnboarding progress and step data.
user_rolesRoles assigned to the user.
user_trust_levelsModeration trust level.
sync_checkpointsOffline-sync checkpoints.
experiment_assignmentsExperiment variants assigned to the user.
experiment_exposuresExperiment exposures logged for the user.
push_deliveriesPush notifications sent to the user, with their status.
push_topic_subscriptionsPush topics the user subscribed to.
friendshipsFriendships on either side; each line carries the other person's user id (see Shared spaces for blocks).
groupsGroups the user owns.
group_membersThe user's own group memberships.
conversation_participantsThe user's own conversation memberships.
messagesMessages the user sent.
message_attachmentsAttachments on the messages the user sent.
message_reactionsReactions the user left.
feed_itemsFeed items the user's actions created.
reviewsReviews the user wrote.
moderation_queueReports the user filed, with their outcome.
referral_codesThe user's referral codes.
referral_claimsClaims on either side: someone used the user's code, or the user used someone else's.
link_clicksTracked-link clicks. ip_address is null: clicks store no IP address, and the database update that shipped with that change clears earlier ones.
affiliate_touchpointsAffiliate code binds and link clicks recorded under the user's id, or under an anonymous id that only this user holds.
content_itemsContent items the user authored.
media_assetsFiles the user uploaded; each line carries the file's storage_url.
email_deliveriesEmails sent to the user, by user id or to the user's email address.
email_suppressionsThe suppression record of the user's email address (bounce, complaint or manual).
audit_eventsAudit events recorded for the user.
_amba_write_idempotencyIdempotency keys sent with the user's collection inserts, with the collection and the id of the row each one created.
collectionsThe user's rows in every collection (see Collection rows).
engagement_eventsThe user's engagement events, oldest first.
telemetry_eventsEvents tracked with { telemetry: true }, oldest first. The last data section.
export_completeThe closing line.

Rows keyed to an address (magic_link_tokens requested before the account existed, email_otp_challenges, sms_otp_challenges, sms_outbound_messages, email_deliveries sent to the address alone, email_suppressions) match the user's current email address or phone number when the user has proven it: email_verified or phone_verified is true on the profile (a profile marked legacy_password has an unproven email). Two accounts can hold the same unproven address, and that address's sign-in codes and mail belong to whoever proved it. The one-time codes and texts are stored under a hash of the address, and the bundle matches them with the same hash. agent_checkouts comes from the project's account records; every other section comes from the project's database.

A section the user has no rows in contributes zero data lines and a 0 in the closing line. A table the project's database has not created yet (a migration still to apply) also contributes zero lines, with no marker.

Each event line carries the stored row: id, app_user_id, event_name, properties ({} when the event was tracked without properties), and occurred_at (the timestamp sent with the event, or the time the API received it). Engagement events add external_id (the event_id idempotency key a server-side track call sent, or null) and received_at (when the event was recorded, or null on older events). Telemetry events add ingested_at (when the API stored the event). The two event sections can hold the most rows, so they come last among the data sections, telemetry after engagement.

Shared spaces

In conversations, groups and feeds the bundle carries what the user wrote and the user's own membership rows: the user's messages, the attachments on them, the user's reactions, the user's conversation_participants and group_members rows, and the feed items the user's actions created. Each line carries the conversation_id, group_id or parent_message_id it belongs to as context. Other people's messages, reactions and memberships in the same spaces are their data and appear in their own exports. A blocked friendship in which the user is the addressee is left out: the row records no blocker, and when someone else blocked the user it names that person, whose choice the app keeps from the person blocked. Blocks the user made, with the user as requester, are exported.

Collection rows

{
  "section": "collections",
  "collection": "notes",
  "data": {
    "id": "…",
    "user_id": "…",
    "body": "…",
    "created_at": "…",
    "updated_at": "…",
    "deleted_at": null
  }
}

One line per row whose user_id is the user, in every collection, with every column. In a group-owned collection the group owns the row and user_id names the member who created it, so the section carries the rows the user created. Soft-deleted rows are still stored, so they are exported too, with their deleted_at. A row another user owns that points at this user through a reference column you declared is that other user's row and appears in their export.

Left out

The bundle leaves out five credential columns: password_hash (app_users), refresh_token_hash (app_user_sessions), token_hash (magic_link_tokens), and code_hash (email_otp_challenges, sms_otp_challenges). Each is a hash that proves possession of a password, a session, a sign-in link or a one-time code, and a copy in a file would let whoever holds the file attack it offline. Every other column is the user's data and is exported.

Closing line

The last line of a whole bundle:

{
  "section": "export_complete",
  "data": {
    "sections": {
      "app_users": 1,
      "account_links": 1,
      "push_tokens": 2,
      "…": 0,
      "telemetry_events": 418
    },
    "total_rows": 977,
    "failed_sections": [],
    "generated_at": "2026-10-08T14:03:11.402Z"
  }
}
  • sections — the data lines written per section, every section listed (0 when the user has none).
  • total_rows — their sum, the profile line included.
  • failed_sections — the sections that wrote a partial-export marker.
  • generated_at — when the API finished writing the bundle.

A bundle cut off by a dropped connection or the API's request time limit ends without this line; request the export again. A bundle that ends with export_complete and an empty failed_sections holds every row the sections above match, and each section's line count equals its entry in sections. Address-keyed rows are matched on the user's current, proven address, so rows sent to an address the user held earlier are outside the bundle.

Partial-export markers

If a single section's cursor errors mid-stream (e.g. a transient DB blip), the bundle carries on: the handler logs server-side and emits one marker line (a failed collection read adds its collection key):

{
  "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, and the closing line lists the section in failed_sections. Treat the marker as "this section is partial up to this line".

Response 200

Sample (truncated for clarity):

{"section":"app_users","data":{"id":"…","external_id":null,"anonymous_id":"anon_…","email":"alice@example.com","phone":"+15550001111","display_name":"OakHiker","avatar_url":null,"auth_providers":[{"provider":"apple","provider_id":"…"}],"properties":{},"first_seen_at":"…","last_seen_at":"…","created_at":"…","updated_at":"…","timezone":"Europe/Berlin","phone_verified":true,"email_verified":true,"session_epoch":0,"legacy_password":false,"password_set_at":null}}
{"section":"account_links","data":{"id":"…","app_user_id":"…","anonymous_id":"anon_…","linked_at":"…"}}
{"section":"push_tokens","data":{"id":"…","app_user_id":"…","token":"…","platform":"ios","provider":"apns","is_active":true,"created_at":"…","updated_at":"…"}}
{"section":"messages","data":{"id":"…","conversation_id":"…","sender_id":"…","body":"see you at 6","message_type":"text","metadata":{},"created_at":"…","parent_message_id":null}}
{"section":"collections","collection":"notes","data":{"id":"…","user_id":"…","body":"…","created_at":"…","updated_at":"…","deleted_at":null}}
{"section":"engagement_events","data":{"id":"…","app_user_id":"…","event_name":"workout_completed","properties":{},"occurred_at":"…","external_id":null,"received_at":"…"}}
{"section":"telemetry_events","data":{"id":"…","app_user_id":"…","event_name":"quiz_step","properties":{"step":3},"occurred_at":"…","ingested_at":"…"}}
{"section":"export_complete","data":{"sections":{"app_users":1,"account_links":1,"…":0,"telemetry_events":1},"total_rows":7,"failed_sections":[],"generated_at":"…"}}

Errors

  • 400 INVALID_USER_ID — userId 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/${PROJECT_ID}/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_IDS — user_ids empty or absent.
  • 400 INVALID_USER_IDS — user_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' \
  -H 'Content-Type: application/json' \
  -d '{
  "user_ids": [
    "<app_user_id>"
  ],
  "updates": {
    "properties": {
      "plan": "pro"
    }
  }
}'
Loading auth… Configure auth in the settings drawer (top-right) to run this request.

Curl:

curl -X POST "${BASE_URL}/admin/projects/${PROJECT_ID}/users/bulk-update" \
  -H "Authorization: Bearer ${DEV_TOKEN}" \
  -H 'Content-Type: application/json' \
  -d '{"user_ids":["<app_user_id>"],"updates":{"properties":{"plan":"pro"}}}'

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_ID — entitlement_id is missing or empty.
  • 400 INVALID_PRODUCT_ID — product_id is not a string or null.
  • 400 INVALID_STORE — store is not one of app_store, play_store, stripe (or null).
  • 400 INVALID_PERIOD_TYPE — period_type is not one of trial, intro, normal (or null).
  • 400 INVALID_PURCHASE_DATE — purchase_date is not parseable ISO-8601.
  • 400 INVALID_EXPIRATION_DATE — expiration_date is not parseable ISO-8601.
  • 400 INVALID_RAW_DATA — raw_data is not an object or null (arrays / scalars rejected).
  • 400 INVALID_USER_ID — userId 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/${PROJECT_ID}/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/${PROJECT_ID}/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/${PROJECT_ID}/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_COUNT — count 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/${PROJECT_ID}/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. A state-only reset keeps the user's media, tracked-link clicks, and affiliate touchpoints. A ?delete=true reset purges the media, clears the user id on the clicks, and clears the anonymous id on the affiliate touchpoints like DELETE /admin/projects/:projectId/users/:userId, and adds the same media_purge field to the response.

Errors

  • 400 INVALID_USER_ID — userId is not a valid UUID.
  • 404 — no such user (an already-deleted id).
  • 503 MEDIA_PURGE_UNAVAILABLE — ?delete=true only: the media purge could not be scheduled, so nothing was reset or deleted. Retry the call.
  • 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/${PROJECT_ID}/users/{userId}/reset" \
  -H "Authorization: Bearer ${DEV_TOKEN}"
 
# Reset, then delete the user row
curl -X DELETE "${BASE_URL}/admin/projects/${PROJECT_ID}/users/{userId}/reset?delete=true" \
  -H "Authorization: Bearer ${DEV_TOKEN}"

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

Permanently delete one user, for example to honor an account-deletion request. In one transaction the delete removes the user with their sessions, push tokens, segment memberships, XP, achievements, streaks, balances, inventory, entitlements, friendships, group memberships, the messages they sent, and all of their events, engagement and telemetry alike. After the delete, none of the user's events appear in event lists, counts, analytics, funnels, or exports.

The delete also reaches rows other users share with them: groups the user owns are deleted with their members. Replies other users wrote to the user's messages stay. Each such reply keeps its body, sender, attachments, and other users' reactions, and its parent_message_id becomes null, so it reads as a top-level message in the conversation. The user's own replies to other users' messages are deleted with the rest of their messages. Some records stay. Content items the user owns, referral codes, and moderation reports the user filed keep their row with the user's id cleared. The user's affiliate touchpoints (the affiliate link clicks and code binds recorded through POST /v1/client/affiliate/touchpoint) stay for the affiliate program's click counts: the delete clears the anonymous id each one was recorded under (the user's own and any linked to the user) and keeps its affiliate code, source, and time. The user's tracked-link clicks stay as anonymous clicks: the delete clears each click's user id and keeps its link, platform, and time (clicks store no IP address), so the link's total_clicks and clicks_by_platform stay the same and unique_users counts the users who still exist (see link stats). The email delivery log and audit events keep the user's id. A delete emits user.deleted.

The delete also purges the media the user uploaded, in every bucket. In the same transaction the user's assets leave every media read and media usage, and the media catalog entries that point at them are removed. Right after the commit each asset's stored file is deleted, then its record. A file that cannot be deleted at once (a storage error) is retried in the background until it is gone, and an asset registered in the six hours before the delete has its file deleted again six hours after registration, which also removes the file of an upload still streaming at the delete that finishes within that time; the user delete itself completes either way. The user's session can no longer get an upload URL. Assets the project uploaded and other users' assets stay. See Cascade on user delete.

To delete by the anonymous id your app holds, call DELETE /admin/projects/:projectId/users?anonymous_id=<id>. It removes the same rows.

Response 200

{
  "data": {
    "deleted_id": "…",
    "mau_count": 41,
    "mau_cap": 1000,
    "media_purge": { "assets": 3, "purged": 3, "pending": 0 }
  }
}

mau_count is the project's monthly active users after the delete. mau_cap is omitted on plans without a cap. The anonymous_id form also returns deleted_anonymous_id.

media_purge describes the media purge: assets is the number of media assets the user had uploaded, purged the assets whose stored file and record are gone, and pending the assets the background retry still has to finish. Pending assets are already hidden from every read.

Errors

  • 400 INVALID_USER_ID — userId is not a valid UUID.
  • 400 MISSING_IDENTIFIER — the anonymous_id form was called without anonymous_id.
  • 404 USER_NOT_FOUND — no such user (an already-deleted id).
  • 503 MEDIA_PURGE_UNAVAILABLE — the media purge could not be scheduled, so nothing was deleted. Retry the delete.
  • 500 DELETE_FAILED.

Try it:

DELETE/admin/projects/%7B%7BprojectId%7D%7D/users/%7B%7BuserId%7D%7D
developer auth
curl -X DELETE '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:

# Delete by user id
curl -X DELETE "${BASE_URL}/admin/projects/${PROJECT_ID}/users/{userId}" \
  -H "Authorization: Bearer ${DEV_TOKEN}"
 
# Delete by anonymous id
curl -X DELETE "${BASE_URL}/admin/projects/${PROJECT_ID}/users?anonymous_id={anonymousId}" \
  -H "Authorization: Bearer ${DEV_TOKEN}"