Customers

Look up a customer by the app_user_id you assign and read the entitlements they hold right now, across every device they have purchased on.

A customer is a single end user of your app, identified by the app_user_id you assign. The customer object is the authoritative answer to "what can this user access right now?". Its entitlements map reflects every validated purchase across the user's devices.

The customer object#

app_user_idstring

The identifier you assigned this user, e.g. u_812.

entitlementsobject

A map of entitlement ID to its current state for this customer.

Show entitlement state
is_activeboolean

Whether the entitlement currently grants access.

expires_atstring

ISO 8601 expiry, or null for lifetime access.

platformstring

The last-seen platform, e.g. ios or android.

countrystring

ISO 3166-1 alpha-2 country code from the last session, e.g. US.

last_seen_atstring

ISO 8601 timestamp of the customer's most recent SDK activity.

List customers#

GET /v1/apps/{app}/customers

Returns the app's customers, most recently active first. Supports limit and cursor pagination.

appstringrequired

The app ID.

limitinteger

Page size, up to 100.

cursorstring

Cursor from the previous page's next_cursor.

bash
curl "https://api.cashsdk.com/v1/apps/app_2mK4pQ/customers?limit=2" \
  -H "Authorization: Bearer csk_sk_..."
json
{
  "data": [
    {
      "app_user_id": "u_812",
      "entitlements": {
        "pro": { "is_active": true, "expires_at": "2026-08-17T18:24:05Z" }
      },
      "platform": "ios",
      "country": "US",
      "last_seen_at": "2026-07-17T18:24:05Z"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJpZCI6InVfODEyIn0"
}

Retrieve a customer#

GET /v1/apps/{app}/customers/{app_user_id}

Returns a single customer with their full entitlement state.

appstringrequired

The app ID.

app_user_idstringrequired

The customer's app_user_id, e.g. u_812.

environmentstring

Sandbox, Production or auto. Defaults to the app's environment. auto answers from whichever environment grants access, Production first, so a backend that serves TestFlight and App Store users asks once instead of once per environment. The same value works on GET /v1/customers/{app_user_id}/entitlements, whose environment field says which one answered. A test or live key is already pinned to one environment; auto agrees with it.

bash
curl https://api.cashsdk.com/v1/apps/app_2mK4pQ/customers/u_812 \
  -H "Authorization: Bearer csk_sk_..."
json
{
  "app_user_id": "u_812",
  "entitlements": {
    "pro": { "is_active": true, "expires_at": "2026-08-17T18:24:05Z" }
  },
  "platform": "ios",
  "country": "US",
  "last_seen_at": "2026-07-17T18:24:05Z"
}

Move purchases to another customer#

POST /v1/customers/{app_user_id}/transfer

Moves every store purchase this customer owns to another customer of the same app: the subscriptions and one-time unlocks, with their revenue and offer history. Use it when your backend merges two accounts, for example a guest who bought and then signed into an existing account. It works under every restore policy, and the device does not need to run Restore.

app_user_idstringrequired

The customer whose purchases move.

to_app_user_idstringrequired

The customer who receives them. Created if the app has not seen it yet.

environmentstring

Sandbox or Production to move only that environment. Omit for both. A test key moves only Sandbox purchases.

bash
curl https://api.cashsdk.com/v1/customers/guest_42/transfer \
  -H "Authorization: Bearer csk_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "to_app_user_id": "u_812" }'
json
{
  "object": "transfer",
  "from_app_user_id": "guest_42",
  "to_app_user_id": "u_812",
  "environment": null,
  "transferred": [
    {
      "original_transaction_id": "2000000912345678",
      "store": "app-store",
      "environment": "Production",
      "product_identifier": "pro.monthly"
    }
  ],
  "skipped": []
}

Both customers receive subscription.transfer, and the sender receives subscription.expired for any access it loses. Calling it again moves nothing new, so it is safe to retry. A retry also sends any webhooks an interrupted call left unsent, with the same event ids.

What stays with the sender:

  • Manual grants.
  • Consumables. Their units were credited to the sender's balance, which does not move, so the purchases stay with it and are listed in skipped with "reason": "consumable".

When it is refused:

  • 409 receiver_has_active_subscription: the receiving customer already has a subscription in a group one of the purchases grants, and it is active or the store may still renew it (grace period, billing retry, on hold, paused, or auto-renew on). The same goes for a sender's subscription in that state. A customer holds one subscription per group, so one of the two paid subscriptions would grant nothing. Nothing is moved. Decide which one the person keeps (for example, ask them to cancel one in the store) and call it again once that one has ended.
  • 409 customer_deleted: the receiving customer was deleted.
  • 409 transfer_in_progress or 503: retry. The message names any purchases that moved before the call stopped.
  • 503 transfer_incomplete: the purchases moved, but their webhooks or the refresh of cached access did not all go out. Retry the same call: it finishes them and lists those purchases in transferred. If you never retry, CashSDK finishes them within about ten minutes.

Next steps#

Record customer profiles#

PATCH /v1/customers/{appUserId}/profile

From your backend, send the attributes your customer has supplied. The customer must already exist in this app. Use a live server key, or a live restricted key with customers:write. Never put that key in your mobile app or browser.

bash
curl -X PATCH "https://api.cashsdk.com/v1/customers/u_812/profile" \
  -H "Authorization: Bearer $CASHSDK_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"display_name":"Alex","email":"alex@example.com","phone_number":"+14155550123","country":"US","locale":"en-US","city":"Chicago","region":"Illinois","timezone":"America/Chicago"}'
FieldValue
display_nameCustomer's chosen name, up to 200 characters.
emailEmail supplied by your authenticated account system. Apple private relay addresses are supported.
phone_numberPhone number in international E.164 format, such as +14155550123.
sign_in_with_apple_idOpaque user identifier (sub) from an Apple sign-in your backend has verified, up to 256 characters. Never send a JWT, access token or authorization code.
countryTwo-letter country code, such as US. Stored in uppercase.
localeLanguage tag, such as en-US. Underscores are normalized.
cityCustomer-provided city, up to 128 characters.
regionCustomer-provided state, province or region, up to 128 characters.
timezoneIANA timezone, such as America/Chicago. This does not change the dashboard viewer's reporting timezone.
genderA self-reported label, up to 64 characters. Do not infer it.

Omitted fields keep their values. Send null to clear a field. Unknown fields, empty strings and malformed values return 400. An unknown customer returns 404; an erased customer returns 409 and is not recreated.

Profiles are shared across this app's stores and environments. A test key returns 403 live_key_required to prevent Sandbox integrations from changing Production profiles. These fields do not grant access or assign purchases.

Send the profile after the SDK has identified the customer, using the same app user ID as your authenticated backend. Update it after account changes and clear removed fields with null. Check the response before marking a sync complete. A 404 means that app user has not yet been recorded; retry after identification completes. The API records which fields changed without putting their values in the audit log.

The Customer details page displays contact information, sign-in identifiers, location, language and existing purchase history. Personal details are masked until you choose Reveal personal details. CashSDK records these values as supplied by your app; it does not verify ownership of an email address or phone number.

Apple purchase records do not supply the customer's Apple Account email or phone. The app account token is a purchase-linking identifier, not an Apple Account ID. If your app uses Sign in with Apple, save the user identifier and email from your verified sign-in flow. The email may be a private relay address, including privaterelay.appleid.com or private.icloud.com. An email alone does not establish that the customer used Sign in with Apple. Missing data stays unavailable. See Apple's transaction fields and Sign in with Apple identity.

In Analytics → Audience breakdown, select Customer country, Gender or Language / locale. These views use the latest recorded profile, including when viewing older purchases. SDK activity can update country and locale later. Purchase country is a separate dimension taken from the matching store record. Missing attributes and purchases without a customer remain in Unknown. Choose All stores and Purchase store to compare Apple and Google using the store that processed each purchase; Customer platform is the latest device platform and can differ.