Skip to content

Users ​

A user is a person you pay, created under your merchant account. They become payable once they finish onboarding in Zay — see How onboarding works.

New to the API? Start with Get started.

EndpointOperation
POST /merchants/v1/usersCreate user
GET /merchants/v1/users/{id}Retrieve user
GET /merchants/v1/usersList users
GET /merchants/v1/users/by_wallet_addressLook up user by wallet address
GET /merchants/v1/users/{id}/balanceRetrieve user balance

Events: user.onboarding_started, user.kyc_submitted, user.kyc_passed, user.onboarding_completed, user.onboarding_failed

The User object ​

Balances are not embedded in the user — query them separately with Retrieve user balance.

json
{
  "user": {
    "id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
    "external_id": null,
    "phone_number": "+924572882763",
    "email": null,
    "created_at": "2025-12-05T12:05:49.000Z",
    "account_state": "onboarding",
    "wallet_address": null,
    "onboarding_url": "https://privy-stag.zay.me/onboarding?merchant_id=b902577d-d94c-54d7-8f83-933cac55f470&user_id=2c9596e6-7c48-4fe9-b957-738bd2e04adc"
  }
}
FieldTypeDescription
idstring (uuid)
external_idstring | nullYour own id for this user, as supplied when creating them. Null when not supplied. Used as the analytics join key across the user's whole journey.
phone_numberstringUser phone number
emailstring | nullMerchant-provided email for the user. Optional reference data, not used to match Zay accounts. Null when not supplied.
created_atstring (date-time)Creation date and time (ISO-8601)
account_statestringAccount lifecycle state. onboarding until the user's card is issued, then active. pending_closure, closing and closed follow a request to close the account. One of onboarding, active, pending_closure, closing, closed.
wallet_addressstring | nullThe user's wallet address — where payouts are sent. Null until the profile is linked to a Zay account and the account's wallet exists; always set once account_state is active.
onboarding_urlstring (uri)Where to send this user to onboard. Redirect them here, appending your callback_url to bring them back; it can also be embedded in an iframe. Not a secret: the one-time code the user enters during onboarding is the security boundary.

The Balance object ​

Per-merchant balances of a user, computed live over their payouts.

json
{
  "balance": {
    "confirmed": "70.0",
    "pending": "30.0",
    "committed": "100.0",
    "currency_code": "USDC"
  }
}
FieldTypeDescription
confirmedstringDecimal amount serialised as a string. Must be greater than 0 and at most 10 000.
pendingstringDecimal amount serialised as a string. Must be greater than 0 and at most 10 000.
committedstringDecimal amount serialised as a string. Must be greater than 0 and at most 10 000.
currency_codestringCurrency code One of USDC.

Create user ​

Create a user under your merchant account. The new user starts in account_state onboarding.

The response is identical whether or not the phone number already belongs to a Zay account: you always get a pending profile, with account_state onboarding and wallet_address null. Creating a user never attaches it to an existing Zay wallet — that happens only once the person verifies their phone during onboarding (see How onboarding works). This endpoint therefore cannot be used to find out whether a number already has a Zay account.

The response carries onboarding_url — where you send this user to onboard.

POST /merchants/v1/users

Request body

FieldTypeDescription
phone_numberrequiredstringUser phone number
emailstringOptional merchant-provided email (reference data, not used to match Zay accounts)
external_idstringOptional: your own id for this user. Stored and used as the analytics join key so this user's pre- and post-signup events assemble into one journey. Unique within your merchant account.

The phone number is normalized server-side via Phonelib and must be a plausible E.164 value. Within a single merchant, phone_number and external_id are each unique — creating a second user with the same value returns 422.

When supplied, email is trimmed and lower-cased (a blank value is stored as null). It is validated only loosely — a clearly malformed address is rejected with 422, but since it is reference data and not a login, the check is intentionally permissive.

Returns 201 Created with a wrapped user object.

Errors:

  • 401 — invalid signature.
  • 422 — phone number missing, invalid or already used; external_id already used; or email malformed.
  • 429 — rate limit exceeded.

Examples ​

cURL ​

bash
curl -X POST "https://api-connect.zay.me/merchants/v1/users" \
  -H "Content-Type: application/json" \
  -H "zay-api-key: $ZAY_API_KEY" \
  -H "zay-timestamp: $TS" \
  -H "zay-signature: $SIG" \
  -H 'Idempotency-Key: "d7c59ab2-3f12-4ad8-9b03-2a7b4f9c7e10"' \
  -d '{
    "phone_number": "+79210000000",
    "email": "user@example.com",
    "external_id": "acme-user-42"
  }'

Python ​

python
import json
import uuid

import requests

body = json.dumps({"phone_number": "+79210000000", "email": "user@example.com", "external_id": "acme-user-42"})
url = "https://api-connect.zay.me/merchants/v1/users"
headers = {
    "Content-Type": "application/json",
    "zay-api-key": ZAY_API_KEY,
    "Idempotency-Key": f'"{uuid.uuid4()}"',
    **merchant_headers("POST", url, ZAY_PRIVATE_KEY, body),
}
r = requests.post(url, headers=headers, data=body)
user = r.json()["user"]

Node.js ​

javascript
const url = "https://api-connect.zay.me/merchants/v1/users";
const body = JSON.stringify({ phone_number: "+79210000000", email: "user@example.com", external_id: "acme-user-42" });
const res = await fetch(url, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "zay-api-key": ZAY_API_KEY,
    "Idempotency-Key": `"${crypto.randomUUID()}"`,
    ...merchantHeaders("POST", url, ZAY_PRIVATE_KEY, body),
  },
  body,
});
const { user } = await res.json();

Response ​

json
{
  "user": {
    "id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
    "external_id": "acme-user-42",
    "phone_number": "+79210000000",
    "email": "user@example.com",
    "created_at": "2025-12-05T12:05:49.000Z",
    "account_state": "onboarding",
    "wallet_address": null,
    "onboarding_url": "https://privy-stag.zay.me/onboarding?merchant_id=b902577d-d94c-54d7-8f83-933cac55f470&user_id=2c9596e6-7c48-4fe9-b957-738bd2e04adc"
  }
}

Retrieve user ​

Retrieve a user by their id, including their current account_state.

GET /merchants/v1/users/{id}

Parameters

ParameterTypeDescription
idrequiredstring (uuid), pathUser ID

Errors:

  • 401 — invalid signature.
  • 404 — user does not exist under this merchant.
  • 429 — rate limit exceeded.

Example ​

bash
curl "https://api-connect.zay.me/merchants/v1/users/2c9596e6-7c48-4fe9-b957-738bd2e04adc" \
  -H "zay-api-key: $ZAY_API_KEY" \
  -H "zay-timestamp: $TS" \
  -H "zay-signature: $SIG"

Response ​

json
{
  "user": {
    "id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
    "external_id": null,
    "phone_number": "+924572882763",
    "email": null,
    "created_at": "2025-12-05T12:05:49.000Z",
    "account_state": "onboarding",
    "wallet_address": "0x742d35cc6634c0532925a3b844bc9e7595f0beb7",
    "onboarding_url": "https://privy-stag.zay.me/onboarding?merchant_id=b902577d-d94c-54d7-8f83-933cac55f470&user_id=2c9596e6-7c48-4fe9-b957-738bd2e04adc"
  }
}

List users ​

List every user under your merchant with cursor pagination. Results are ordered by descending creation time, so the newest user comes first. Each item is a full User object.

GET /merchants/v1/users

Parameters

ParameterTypeDescription
page_sizeinteger, queryPage size
page_cursorstring, queryPage identifier — the next_cursor of the previous page

Pass the previous response's next_cursor as page_cursor to get the next page. When next_cursor is null, the list is exhausted. page_size ranges 1..1000 and defaults to 1000.

Errors:

  • 401 — invalid signature.
  • 429 — rate limit exceeded.

Examples ​

cURL ​

bash
curl "https://api-connect.zay.me/merchants/v1/users?page_size=100" \
  -H "zay-api-key: $ZAY_API_KEY" \
  -H "zay-timestamp: $TS" \
  -H "zay-signature: $SIG"

Python ​

python
url = "https://api-connect.zay.me/merchants/v1/users"
users, cursor = [], None
while True:
    params = {"page_size": 100, **({"page_cursor": cursor} if cursor else {})}
    r = requests.get(url, params=params, headers={
        "zay-api-key": ZAY_API_KEY,
        **merchant_headers("GET", url, ZAY_PRIVATE_KEY),
    })
    data = r.json()
    users += data["users"]
    cursor = data.get("next_cursor")
    if not cursor:
        break

Node.js ​

javascript
const url = new URL("https://api-connect.zay.me/merchants/v1/users");
url.searchParams.set("page_size", "100");
const res = await fetch(url, {
  headers: {
    "zay-api-key": ZAY_API_KEY,
    ...merchantHeaders("GET", url.toString(), ZAY_PRIVATE_KEY),
  },
});
const { users, next_cursor: nextCursor } = await res.json();

Response ​

json
{
  "users": [
    {
      "id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
      "external_id": "acme-user-42",
      "phone_number": "+924572882763",
      "email": null,
      "created_at": "2025-12-05T12:05:49.000Z",
      "account_state": "onboarding",
      "wallet_address": "0x742d35cc6634c0532925a3b844bc9e7595f0beb7",
      "onboarding_url": "https://privy-stag.zay.me/onboarding?merchant_id=b902577d-d94c-54d7-8f83-933cac55f470&user_id=2c9596e6-7c48-4fe9-b957-738bd2e04adc"
    },
    {
      "id": "1b4b7c35-0f0e-4f43-9a52-5e2d0e4c8a11",
      "external_id": null,
      "phone_number": "+924572882764",
      "email": null,
      "created_at": "2025-12-05T12:05:49.000Z",
      "account_state": "onboarding",
      "wallet_address": null,
      "onboarding_url": "https://privy-stag.zay.me/onboarding?merchant_id=b902577d-d94c-54d7-8f83-933cac55f470&user_id=1b4b7c35-0f0e-4f43-9a52-5e2d0e4c8a11"
    }
  ],
  "next_cursor": "IjBhN2UyZjkxLTZkM2MtNGI4ZS04ZjFhLTNjOWI1ZDdlMmY0MCI=--de6f22bd9aae01cca9a02164d13b77ecb320a845"
}

Look up user by wallet address ​

Find a user under your merchant by their wallet address — the wallet_address on the User object. Useful when you observe the address on-chain and need to map it back to your user. The address is matched case-insensitively, so an EIP-55 checksummed address works as well as a lowercased one.

GET /merchants/v1/users/by_wallet_address

Parameters

ParameterTypeDescription
addressrequiredstring, queryThe user's wallet address, as returned in wallet_address. Matched case-insensitively.

Errors:

  • 401 — invalid signature.
  • 404 — no user under this merchant has that wallet address.
  • 429 — rate limit exceeded.

Response ​

json
{
  "user": {
    "id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
    "external_id": null,
    "phone_number": "+924572882763",
    "email": null,
    "created_at": "2025-12-05T12:05:49.000Z",
    "account_state": "onboarding",
    "wallet_address": "0x742d35cc6634c0532925a3b844bc9e7595f0beb7",
    "onboarding_url": "https://privy-stag.zay.me/onboarding?merchant_id=faba407a-362d-53a8-9b49-662bb3b6295f&user_id=2c9596e6-7c48-4fe9-b957-738bd2e04adc"
  }
}

Retrieve user balance ​

Returns the user's current balance, computed live from their payouts under your merchant account.

GET /merchants/v1/users/{id}/balance

Parameters

ParameterTypeDescription
idrequiredstring (uuid), pathUser ID

Errors:

  • 401 — invalid signature.
  • 404 — user does not exist under this merchant.
  • 429 — rate limit exceeded.

Example ​

bash
curl "https://api-connect.zay.me/merchants/v1/users/$USER_ID/balance" \
  -H "zay-api-key: $ZAY_API_KEY" \
  -H "zay-timestamp: $TS" \
  -H "zay-signature: $SIG"

Response ​

json
{
  "balance": {
    "confirmed": "70.0",
    "pending": "30.0",
    "committed": "100.0",
    "currency_code": "USDC"
  }
}