Skip to content

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 needHow to get it
Staging credentials: your merchant id and private keyAsk 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 SMSYour own is fine. You'll use it to sign in as the test user.
An email inbox you can readThe onboarding sends a 6-digit code to it.
A public HTTPS URL that shows you incoming requestsFor 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 uuidgenPreinstalled 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_endpoints

You should see an empty list, unless your Zay contact already registered an endpoint for you:

{"webhook_endpoints":[]}
HTTP 200

If 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_delivery

You 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:

  1. Phone — enter the code you receive by SMS.
  2. Email — enter the 6-digit code sent to your inbox.
  3. Country of residence — pick United States, wherever your phone number is from.
  4. Terms — accept the card issuer's terms.
  5. 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.
  6. 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_ID

That'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-signature with your signing_secret before 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.