Appearance
Get started with the Merchant API
Create a user, take them through onboarding, and send them their first payout — on staging, in about 30 minutes.
Already integrated?
Go straight to the API reference.
What you'll build
By the end of this tutorial you will have:
- signed requests to the Merchant API from your terminal;
- a webhook endpoint registered to receive Zay's events;
- a user who has finished onboarding and holds a card;
- a reported payout that Zay has confirmed as
received.
Before you begin
Everything here runs against staging. Nothing in this tutorial touches real money.
| You need | How to get it |
|---|---|
| Staging credentials: your merchant id and private key | Ask your Zay contact in your shared Slack channel. The private key arrives as a one-time link — copy it somewhere safe, it cannot be shown again. |
| A phone that can receive SMS | Your own is fine. You'll use it to sign in as the test user. |
| An email inbox you can read | The onboarding sends a 6-digit code to it. |
| A public HTTPS URL that shows you incoming requests | For this tutorial, a request inspector such as webhook.site is enough. Zay refuses localhost and private addresses. |
A terminal with bash or zsh, curl, openssl and uuidgen | Preinstalled on macOS. On Linux, uuidgen comes with util-linux (uuid-runtime on Debian and Ubuntu). |
1. Set up a signed request
Every request to the Merchant API carries three headers: your merchant id, a timestamp, and an HMAC-SHA256 signature of the request. Paste this into your terminal once. It defines a zay command that signs and sends a request for you — zay METHOD PATH [JSON_BODY] — and prints the HTTP status after the response. api-connect.zay.me is staging; production is api.zay.me, with its own credentials.
bash
export ZAY_API_KEY='<your merchant id>'
export ZAY_PRIVATE_KEY='<your private key>'
export ZAY_BASE='https://api-connect.zay.me'
zay() {
local method="$1" req_path="$2" body="${3:-}" ts sig
local -a data=()
[ -n "$body" ] && data=(--data-raw "$body")
ts=$(date +%s)
sig=$(printf '%s\n%s\n%s\n%s' "$ts" "$method" "${req_path%%\?*}" "$body" \
| openssl dgst -sha256 -hmac "$ZAY_PRIVATE_KEY" | awk '{print $NF}')
curl -sS -X "$method" "$ZAY_BASE$req_path" \
-H "content-type: application/json" \
-H "zay-api-key: $ZAY_API_KEY" \
-H "zay-timestamp: $ts" \
-H "zay-signature: $sig" \
-H "Idempotency-Key: \"$(uuidgen)\"" \
-w '\nHTTP %{http_code}\n' \
"${data[@]}"
}Replace only the text inside the quotes. Your merchant id is a UUID; your private key is a long hex string. The signature covers the path without its query string, which zay takes care of.
Keep this terminal open for the whole tutorial: the function and the ids you save along the way live only in this shell session.
Now make your first call — list your webhook endpoints:
bash
zay GET /merchants/v1/webhook_endpointsYou should see an empty list, unless your Zay contact already registered an endpoint for you:
{"webhook_endpoints":[]}
HTTP 200If you get HTTP 401 instead, the response body says which part of the signature failed — see Sign requests.
2. Register a webhook endpoint
Zay tells you what happens to your users and payouts by sending events to your endpoint. Register the URL of your request inspector:
bash
zay POST /merchants/v1/webhook_endpoints \
'{"webhook_endpoint":{"url":"https://webhook.site/<your-id>","description":"Get started"}}'You should see the new endpoint, already enabled, and HTTP 201. Responses from here on are formatted for reading — curl prints them on one line:
json
{
"webhook_endpoint": {
"id": "8f0c5c1e-3b9a-4a4e-9f61-2d7e0b6c9a11",
"url": "https://webhook.site/<your-id>",
"description": "Get started",
"status": "enabled",
"created_at": "2026-10-01T12:00:00Z",
"updated_at": "2026-10-01T12:00:00Z",
"signing_secret": "whsec_…"
}
}Save id as ENDPOINT_ID. Also keep signing_secret: it is shown only once, and it's what your real webhook handler will use to check that an event comes from Zay.
bash
export ENDPOINT_ID='<id from the response>'3. Receive a test event
Ask Zay to send a test event to the endpoint:
bash
zay POST /merchants/v1/webhook_endpoints/$ENDPOINT_ID/test_deliveryYou should see the delivery Zay queued, and HTTP 202:
json
{
"webhook_delivery": {
"id": "0192f5b0-1234-7e8d-89ab-cdef01234567",
"type": "webhook_endpoint.test",
"occurred_at": "2026-10-01T12:01:00Z",
"data": {
"webhook_endpoint_id": "8f0c5c1e-3b9a-4a4e-9f61-2d7e0b6c9a11",
"message": "This is a test event from Zay. If you received this, your endpoint is configured correctly."
}
}
}Within a few seconds your request inspector shows a POST with the event itself:
json
{
"id": "0192f5b0-1234-7e8d-89ab-cdef01234567",
"type": "webhook_endpoint.test",
"occurred_at": "2026-10-01T12:01:00Z",
"merchant_id": "<your merchant id>",
"data": {
"webhook_endpoint_id": "8f0c5c1e-3b9a-4a4e-9f61-2d7e0b6c9a11",
"message": "This is a test event from Zay. If you received this, your endpoint is configured correctly."
}
}It comes with three signature headers: webhook-id (the same value as id), webhook-timestamp and webhook-signature. Every event in the rest of this tutorial arrives the same way.
4. Create a user
Create the person you're going to pay. Use your own phone number in international format — any country works. The user signs in with it in the next step, and the numbers must match. Use your own email too:
bash
zay POST /merchants/v1/users \
'{"phone_number":"<your phone, e.g. +15555550100>","email":"<your email>","external_id":"get-started-1"}'You should see the user, still in onboarding, with a ready-made link to Zay's onboarding, and HTTP 201:
json
{
"user": {
"id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
"external_id": "get-started-1",
"phone_number": "+15555550100",
"email": "you@example.com",
"created_at": "2026-10-01T12:02:00.000Z",
"account_state": "onboarding",
"wallet_address": null,
"onboarding_url": "https://privy-stag.zay.me/onboarding?merchant_id=<your merchant id>&user_id=2c9596e6-7c48-4fe9-b957-738bd2e04adc"
}
}Save id as USER_ID:
bash
export USER_ID='<id from the response>'Running it again?
A phone number and an external_id can each belong to only one user of your merchant account, so a second run with the same values returns HTTP 422. Use another number or external_id, or carry on with the USER_ID you already have.
5. Take the user through onboarding
Open onboarding_url in your browser. In your own product you redirect the user there; for now, act as the user and go through the steps:
- Phone — enter the code you receive by SMS.
- Email — enter the 6-digit code sent to your inbox.
- Country of residence — pick United States, wherever your phone number is from.
- Terms — accept the card issuer's terms.
- Identity verification — staging runs it in sandbox mode and accepts any details. The sandbox shows a pass / fail toggle on the verification screen: set it to pass.
- Card — wait a few moments while the card is issued and activated. Activation allows the card to spend from the user's wallet; it happens by itself and costs nothing.
You should end on a screen showing the user's new card. Meanwhile your request inspector receives progress events such as user.onboarding_started and user.kyc_passed.
6. Wait for onboarding to complete
When the card is ready, Zay sends the event that matters most: the user can now be paid.
You should see this in your request inspector:
json
{
"id": "0192f5b0-90ab-7cde-89ab-cdef01234567",
"type": "user.onboarding_completed",
"occurred_at": "2026-10-01T12:08:00Z",
"merchant_id": "<your merchant id>",
"data": {
"user_id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
"wallet_address": "0x742d35cc6634c0532925a3b844bc9e7595f0beb7"
}
}wallet_address is where you send the user's USDC. The same address is now on the user, too: zay GET /merchants/v1/users/$USER_ID shows it, with account_state active.
7. Report a payout
You first send USDC to the user's wallet_address on-chain, then report the transfer to Zay with its transaction hash. To try the call, a made-up hash will do — Zay does not confirm deposits yet (see the next step):
bash
zay POST /merchants/v1/payouts/report \
"{\"user_id\":\"$USER_ID\",\"payout\":{\"amount\":\"10.0\",\"currency_code\":\"USDC\",\"transaction_hash\":\"0x$(openssl rand -hex 32)\"}}"You should see the payout, pending, and HTTP 201:
json
{
"payout": {
"id": "60eb2155-c3b4-43d7-9451-0fe1cc009d85",
"user_id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
"amount": "10.0",
"currency_code": "USDC",
"status": "pending",
"transaction_hash": "0x9f2c…",
"reason": null,
"custom_metadata": {},
"created_at": "2026-10-01T12:09:00Z"
}
}zay sends a new Idempotency-Key on every call, so running this command again reports a second payout. If you are unsure whether it went through, check with zay GET /merchants/v1/payouts before retrying.
Save id as PAYOUT_ID:
bash
export PAYOUT_ID='<id from the response>'8. Receive the confirmation
Zay does not confirm deposits yet, on staging or in production — see How payouts work. The payout you reported stays pending and turns expired after 48 hours, when your request inspector receives payout.expired.
When confirmation is live, a matching deposit turns the payout received and your request inspector receives:
json
{
"id": "0192f5b1-4567-7e8d-89ab-cdef01234567",
"type": "payout.received",
"occurred_at": "2026-10-01T12:10:00Z",
"merchant_id": "<your merchant id>",
"data": {
"id": "60eb2155-c3b4-43d7-9451-0fe1cc009d85",
"user_id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
"amount": "10.0",
"currency_code": "USDC",
"status": "received",
"transaction_hash": "0x9f2c…",
"reason": null,
"custom_metadata": {},
"created_at": "2026-10-01T12:09:00Z"
}
}To exercise your handler before then, feed it this payload signed the way Receive and verify webhooks describes. Read the payout directly at any time:
bash
zay GET /merchants/v1/payouts/$PAYOUT_IDThat's the whole loop: a user onboarded, paid, and confirmed — and your code was told at every step.
Next steps
You've done it once by hand. To build it into your product:
- Onboard a user — redirect your users to onboarding, bring them back, and handle those who stop halfway.
- Receive and verify webhooks — check
webhook-signaturewith yoursigning_secretbefore you trust an event. - How payouts work — what happens when a payout cannot be confirmed.
- Idempotency — why every request above carried an
Idempotency-Key, and how it saves you from paying twice.