Appearance
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.
| Endpoint | Operation |
|---|---|
POST /merchants/v1/users | Create user |
GET /merchants/v1/users/{id} | Retrieve user |
GET /merchants/v1/users | List users |
GET /merchants/v1/users/by_wallet_address | Look up user by wallet address |
GET /merchants/v1/users/{id}/balance | Retrieve 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"
}
}| Field | Type | Description |
|---|---|---|
id | string (uuid) | |
external_id | string | null | Your 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_number | string | User phone number |
email | string | null | Merchant-provided email for the user. Optional reference data, not used to match Zay accounts. Null when not supplied. |
created_at | string (date-time) | Creation date and time (ISO-8601) |
account_state | string | Account 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_address | string | null | The 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_url | string (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"
}
}| Field | Type | Description |
|---|---|---|
confirmed | string | Decimal amount serialised as a string. Must be greater than 0 and at most 10 000. |
pending | string | Decimal amount serialised as a string. Must be greater than 0 and at most 10 000. |
committed | string | Decimal amount serialised as a string. Must be greater than 0 and at most 10 000. |
currency_code | string | Currency 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
| Field | Type | Description |
|---|---|---|
phone_numberrequired | string | User phone number |
email | string | Optional merchant-provided email (reference data, not used to match Zay accounts) |
external_id | string | Optional: 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_idalready used; oremailmalformed.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
| Parameter | Type | Description |
|---|---|---|
idrequired | string (uuid), path | User 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
| Parameter | Type | Description |
|---|---|---|
page_size | integer, query | Page size |
page_cursor | string, query | Page 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:
breakNode.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
| Parameter | Type | Description |
|---|---|---|
addressrequired | string, query | The 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
| Parameter | Type | Description |
|---|---|---|
idrequired | string (uuid), path | User 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"
}
}