Skip to content

ZAY Merchant API · v1

Move money programmatically.

The ZAY Merchant API lets your service create users on behalf of partners, record USDC payouts that you broadcast yourself, and report back the on-chain outcome. Every request is authenticated with HMAC-SHA256, and idempotency is built in so retries are always safe.

Base URL: https://api-connect.zay.me

All endpoints are versioned under /merchants/v1. Use HTTPS for every call — unencrypted traffic is rejected at the edge.

Table of contents


Getting started

Introduction

What you can do with the Merchant API:

  • Create and retrieve users tied to your merchant account
  • Look up a user by their issuer (Kulipa) wallet address — handy when you only have the on-chain identity
  • Record USDC payouts that you broadcast on-chain yourself; Zay verifies the transfer on-chain and notifies you with a payout.received webhook
  • List and inspect payout history with cursor pagination
  • Query a user's balance computed across their payouts

Endpoints at a glance

http
POST   /merchants/v1/users
GET    /merchants/v1/users/{id}
GET    /merchants/v1/users/by_issuer_wallet_address?address=0x…
GET    /merchants/v1/users/{id}/balance

POST   /merchants/v1/payouts/report
GET    /merchants/v1/payouts
GET    /merchants/v1/payouts/{id}

POST   /merchants/v1/events

POST   /merchants/v1/webhook_endpoints
GET    /merchants/v1/webhook_endpoints
GET    /merchants/v1/webhook_endpoints/{id}
PATCH  /merchants/v1/webhook_endpoints/{id}
DELETE /merchants/v1/webhook_endpoints/{id}
POST   /merchants/v1/webhook_endpoints/{id}/test_delivery

Required headers (every request)

http
zay-api-key:     <merchant_api_key>
zay-timestamp:   <unix_seconds>
zay-signature:   <hmac_sha256_hex>
Idempotency-Key: "<uuid>"   (recommended for non-idempotent verbs)

Authentication

Every request is signed. The signature binds the timestamp, method, path, and body together — replaying or tampering invalidates it.

Signature payload

Build the payload string by joining four parts with newlines, in this order:

  1. timestamp — same value sent in zay-timestamp (unix seconds)
  2. method — HTTP verb in upper case
  3. path — request path, e.g. /merchants/v1/payouts
  4. body — raw request body as a string (empty string when there is no body)
payload   = "{ts}\n{METHOD}\n{path}\n{body}"
signature = HMAC_SHA256(private_key, payload).hexdigest()
→ header  = zay-signature: a3f1c9…

Clock skew window: zay-timestamp must be within ±5 minutes of server time, otherwise the request is rejected with 401.

Headers reference

HeaderTypeRequiredPurpose
zay-api-keystringrequiredPublic merchant identifier (UUID)
zay-timestampintrequiredUnix timestamp in seconds
zay-signaturestringrequiredHex-encoded HMAC-SHA256 of the canonical payload
Idempotency-KeystringrecommendedQuoted UUID per RFC 9651, e.g. "d7c59ab2-..."

Sign a request

cURL
bash
TS=$(date +%s)
METHOD="POST"
PATH_="/merchants/v1/payouts/report"
BODY='{"user_id":"a74a29ae-...","payout":{"amount":"100.0","currency_code":"USDC"}}'
PAYLOAD="$TS"$'\n'"$METHOD"$'\n'"$PATH_"$'\n'"$BODY"
SIG=$(printf "%s" "$PAYLOAD" \
  | openssl dgst -sha256 -hmac "$ZAY_PRIVATE_KEY" \
  | awk '{print $2}')
echo "$SIG"
Python
python
import hashlib
import hmac
import time
from urllib.parse import urlparse


def merchant_headers(method, url, private_key, body=""):
    ts = str(int(time.time()))
    path = urlparse(url).path
    payload = f"{ts}\n{method.upper()}\n{path}\n{body}"
    sig = hmac.new(
        private_key.encode("utf-8"),
        payload.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    return {
        "zay-timestamp": ts,
        "zay-signature": sig,
    }
Node.js
javascript
import crypto from "node:crypto";

export function merchantHeaders(method, url, privateKey, body = "") {
  const ts = Math.floor(Date.now() / 1000).toString();
  const path = new URL(url).pathname;
  const payload = `${ts}\n${method.toUpperCase()}\n${path}\n${body}`;
  const sig = crypto
    .createHmac("sha256", privateKey)
    .update(payload)
    .digest("hex");
  return { "zay-timestamp": ts, "zay-signature": sig };
}

Debugging signature failures

The Merchant API returns one of three 401 Unauthorized shapes depending on what went wrong during signature verification:

  1. Missing headerszay-timestamp or zay-signature was not sent.
  2. Timestamp driftzay-timestamp is more than ±5 minutes from server time.
  3. Signature mismatch — the headers were present and the timestamp was fresh, but the HMAC the server computed did not match the one you sent.

The first two return a standard problem-details envelope:

json
{
  "type": "about:blank",
  "title": "Invalid request signature",
  "status": 401,
  "detail": "Missing `zay-timestamp` or `zay-signature` header",
  "instance": null,
  "errors": {}
}
json
{
  "type": "about:blank",
  "title": "Invalid request signature",
  "status": 401,
  "detail": "Request timestamp outside allowed drift of 5 minutes",
  "instance": null,
  "errors": {}
}
Signature mismatch — string_to_sign_bytes

When the headers are present and timestamp is fresh, but the HMAC does not match, the response includes the exact bytes of the canonical string the server tried to sign. This lets you compare it byte-for-byte against what your client built and spot whitespace, trailing-newline, or path-encoding differences that are easy to miss when looking at strings.

The format depends on the request's Accept header.

application/json (default)

string_to_sign_bytes is an array of integers in the range 0..255 — one entry per byte of the canonical payload.

json
{
  "type": "about:blank",
  "title": "Invalid request signature",
  "status": 401,
  "detail": "The request signature we calculated does not match the signature you provided. Check your secret key and signing method.",
  "instance": null,
  "errors": {},
  "string_to_sign_bytes": [49, 55, 51, 51, 49, 56, 50, 52, 48, 48, 10, 71, 69, 84, 10, 47, 109, 101, 114, 99, 104, 97, 110, 116, 115, 47, 118, 49, 47, 112, 97, 121, 111, 117, 116, 115, 10]
}

To re-encode the array back into the string the server saw:

python
# Python
bytes(body["string_to_sign_bytes"]).decode("utf-8")
# → "1733182400\nGET\n/merchants/v1/payouts\n"
javascript
// Node.js
Buffer.from(body.string_to_sign_bytes).toString("utf8");
// → "1733182400\nGET\n/merchants/v1/payouts\n"
ruby
# Ruby
body["string_to_sign_bytes"].pack("C*")
# => "1733182400\nGET\n/merchants/v1/payouts\n"
text/plain

If you set Accept: text/plain, the response body is plain text and the bytes are rendered as space-separated, lowercase, two-digit hex under a StringToSignBytes label:

The request signature we calculated does not match the signature you provided. Check your secret key and signing method.

StringToSignBytes (hex, space-separated, lowercase):
31 37 33 33 31 38 32 34 30 30 0a 47 45 54 0a 2f 6d 65 72 63 68 61 6e 74 73 2f 76 31 2f 70 61 79 6f 75 74 73 0a

To re-encode the hex back into the string:

bash
# bash — paste the hex line after StringToSignBytes
echo "31 37 33 33 31 38 32 34 30 30 0a 47 45 54 0a 2f 6d 65 72 63 68 61 6e 74 73 2f 76 31 2f 70 61 79 6f 75 74 73 0a" \
  | tr -d ' ' | xxd -r -p
python
# Python
bytes.fromhex(hex_line.replace(" ", "")).decode("utf-8")
javascript
// Node.js
Buffer.from(hexLine.replace(/\s+/g, ""), "hex").toString("utf8");
ruby
# Ruby
[hex_line.delete(" ")].pack("H*")

Why bytes and not the string itself? Sending the canonical string verbatim would hide exactly the kind of bug this response is meant to surface — invisible characters (NBSP, BOM, CR before LF, trailing whitespace) collapse or get normalised when rendered as text. The byte array is unambiguous: compare it against your client's payload bytes and any divergence is immediately visible.

Note: string_to_sign_bytes is only present on the signature-mismatch response. Missing-header and timestamp-drift errors deliberately omit it, because in those cases there is no canonical string to compare.


Idempotency

Send the same Idempotency-Key twice — get the same result. Network blips no longer cost you double payouts.

Behavior

  • If the original request is still in-flight, repeating the key returns 409 Conflict.
  • If the original request is complete, the same response is replayed without re-executing the operation.
  • Use a fresh UUID per logical operation. Reusing a key across different operations is undefined behavior.
  • The idempotency cache is keyed by Idempotency-Key together with zay-api-key, so two merchants using the same key produce distinct cache entries.

Format: per RFC 9651, the value should be quoted, e.g. "d7c59ab2-...".

Retry semantics

http
→ POST /merchants/v1/payouts/report        Idempotency-Key: "abc"
   (network drops, no response)

→ POST /merchants/v1/payouts/report        Idempotency-Key: "abc"
← 200 OK                            (original response replayed)

Rate limits

Each merchant gets 100 requests per minute. Exceeding the limit returns 429 Too Many Requests. Apply exponential backoff on retry.

json
{
  "type": "about:blank",
  "title": "Too many requests",
  "status": 429,
  "detail": "Rate limit exceeded",
  "instance": null,
  "errors": {}
}

Errors

Errors follow RFC 7807 (application/problem+json). The status mirrors the HTTP code.

Status codes

StatusMeaning
400Bad request
401Invalid zay-api-key or signature
403Access forbidden
404Resource not found
409Idempotent retry in flight, or invalid status transition
422Validation error in request body
429Rate limit exceeded
503Service unavailable

Error envelope

json
{
  "type": "about:blank",
  "title": "Invalid request signature",
  "status": 401,
  "detail": "Invalid request signature",
  "instance": null,
  "errors": {}
}

Signature 401s have additional shape that helps you debug — see Debugging signature failures.

For 422 errors, the errors object carries field-level validation messages:

json
{
  "type": "about:blank",
  "title": "Invalid request parameters",
  "status": 422,
  "detail": "Validation failed: Amount must be greater than 0",
  "instance": null,
  "errors": {
    "amount": ["must be greater than 0"]
  }
}

Models

The User object

Represents an end-user belonging to your merchant. Balances are not embedded in the user payload — query them separately via the balance endpoint.

FieldTypeDescription
iduuidStable identifier (UUID)
external_idstring | nullYour own id for this user, as supplied on POST /users. null when not supplied. Used as the analytics join key for the user's whole journey.
phone_numberstringE.164 formatted phone number
emailstring | nullMerchant-provided email. Optional reference data, not used to match Zay accounts. null when not supplied
created_atdatetimeISO-8601, UTC
account_stateenumAccount lifecycle state: one of onboarding, active, deactivating, deactivated. Stays onboarding until the user finishes signing up in Zay
issuer_wallet_addressstring | nullIssuer (Kulipa) wallet address. Populated when a Kulipa wallet has been provisioned for the linked user. Otherwise null.

Example

json
{
  "id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
  "external_id": "acme-user-42",
  "phone_number": "+79210000000",
  "email": "[email protected]",
  "created_at": "2025-12-05T12:05:49.000Z",
  "account_state": "onboarding",
  "issuer_wallet_address": null
}

The Payout object

A payout represents a USDC money movement to a user. The merchant broadcasts the on-chain transfer itself, then records it via POST /payouts — passing the broadcast transaction_hash — where it lands in pending. Zay independently verifies that transaction on-chain and, once confirmed, moves the payout to received and emits a payout.received webhook. The merchant does not report the outcome — Zay determines it from the chain.

FieldTypeDescription
iduuidStable identifier (the payout's external identifier)
user_iduuidOwning user (their external identifier)
amountstringDecimal amount serialised as a string. Must be > 0 and <= 10000
currency_codestringCurrency. Currently only USDC is supported
statusenumSee payout statuses
transaction_hashstringOn-chain transaction hash of the transfer the merchant broadcast, supplied when creating the payout
reasonstring | nullFree-text reason, set by Zay when a payout is expired. null otherwise
custom_metadataobjectFree-form JSON bag the merchant attaches to the payout at creation
created_atdatetimeISO-8601, UTC

Example

json
{
  "id": "60eb2155-c3b4-43d7-9451-0fe1cc009d85",
  "user_id": "8775c532-9571-4c70-8bcd-8a822b9a0d9a",
  "amount": "100.0",
  "currency_code": "USDC",
  "status": "pending",
  "transaction_hash": "0xabc123",
  "reason": null,
  "custom_metadata": {},
  "created_at": "2025-12-05T12:05:49Z"
}

The Balance object

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

FieldTypeDescription
confirmedstringSum of received payouts — on-chain-verified (decimal as string)
pendingstringSum of pending payouts — not yet verified (decimal as string)
committedstringconfirmed + pending
currency_codestringCurrency (always USDC today)

Example

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

Payout statuses

pending ──Zay verifies on-chain───────→ received
   └─────verification window elapses──→ expired
  • pending — initial state after reporting a payout. The on-chain transfer has not yet been confirmed by Zay.
  • received — terminal success. Zay has verified the USDC transfer on-chain. On reaching this state, Zay emits a payout.received webhook.
  • expired — terminal failure, set by Zay. The on-chain transfer was not confirmed within the verification window. Zay emits a payout.failed webhook carrying a reason.

received and expired are terminal — payouts cannot leave them.


Users

Create user

POST /merchants/v1/users

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

Body parameters

FieldTypeRequiredDescription
phone_numberstringrequiredE.164 phone number
emailstringoptionalReference email for the user. Not used to match Zay accounts
external_idstringoptionalYour own id for this user. Stored and echoed back, and used as the analytics join key: events you send to POST /events with this external_id — before or after the user exists in Zay — assemble into one journey. Unique within your merchant account.

The phone number is normalised server-side via Phonelib and must be a plausible E.164 value. Within a single merchant, phone_number is unique — creating a second user with the same number 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/duplicate, 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": "[email protected]"
  }'
Python
python
import json
import uuid

import requests

body = json.dumps({"phone_number": "+79210000000", "email": "[email protected]"})
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: "[email protected]" });
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 · 201

json
{
  "user": {
    "id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
    "external_id": "acme-user-42",
    "phone_number": "+79210000000",
    "email": "[email protected]",
    "created_at": "2025-12-05T12:05:49.000Z",
    "account_state": "onboarding",
    "issuer_wallet_address": null
  }
}

Retrieve user

GET /merchants/v1/users/{id}

Retrieve a user by their id. Returns their current account state.

Path parameters

FieldTypeDescription
iduuidThe user identifier

Errors

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

Examples

cURL
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"
Python
python
url = f"https://api-connect.zay.me/merchants/v1/users/{user_id}"
r = requests.get(url, headers={
    "zay-api-key": ZAY_API_KEY,
    **merchant_headers("GET", url, ZAY_PRIVATE_KEY),
})
user = r.json()["user"]
Node.js
javascript
const url = `https://api-connect.zay.me/merchants/v1/users/${userId}`;
const res = await fetch(url, {
  headers: {
    "zay-api-key": ZAY_API_KEY,
    ...merchantHeaders("GET", url, ZAY_PRIVATE_KEY),
  },
});
const { user } = await res.json();

Response · 200

json
{
  "user": {
    "id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
    "phone_number": "+924572882763",
    "email": "[email protected]",
    "created_at": "2025-12-05T12:05:49.000Z",
    "account_state": "onboarding",
    "issuer_wallet_address": null
  }
}

Look up user by issuer wallet address

GET /merchants/v1/users/by_issuer_wallet_address?address=0x…

Find a user under your merchant by the address of their provisioned Kulipa wallet. Useful when you observe an issuer wallet address on-chain and need to map it back to the merchant-side user.

Query parameters

FieldTypeRequiredDescription
addressstringrequiredThe user's issuer (Kulipa) wallet address

Errors

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

Response · 200

json
{
  "user": {
    "id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
    "phone_number": "+924572882763",
    "email": "[email protected]",
    "created_at": "2025-12-05T12:05:49.000Z",
    "account_state": "active",
    "issuer_wallet_address": "0x742d35cc6634c0532925a3b844bc9e7595f0beb7"
  }
}

Retrieve user balance

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

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

Path parameters

FieldTypeDescription
iduuidThe user identifier

Errors

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

Examples

cURL
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"
Python
python
url = f"https://api-connect.zay.me/merchants/v1/users/{user_id}/balance"
r = requests.get(url, headers={
    "zay-api-key": ZAY_API_KEY,
    **merchant_headers("GET", url, ZAY_PRIVATE_KEY),
})
balance = r.json()["balance"]
Node.js
javascript
const url = `https://api-connect.zay.me/merchants/v1/users/${userId}/balance`;
const res = await fetch(url, {
  headers: {
    "zay-api-key": ZAY_API_KEY,
    ...merchantHeaders("GET", url, ZAY_PRIVATE_KEY),
  },
});
const { balance } = await res.json();

Response · 200

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

Onboarding

A user you create with POST /users starts in account_state onboarding and cannot receive payouts until they finish signing up in Zay. Onboarding runs in a Zay-hosted web widget that you embed in your page: the user verifies their phone, confirms their details, and completes identity verification (KYC). When it succeeds, Zay provisions their wallet and card, flips account_state to active, and fires user.onboarding_completed — at which point they're payout-eligible.

Launching the widget

GET https://privy-stag.zay.me/onboarding?merchant_id=<merchant_id>&user_id=<user_id>

The widget is served from Zay's embeds domain (privy-stag.zay.me) — a separate host from the Merchant API base URL (api-connect.zay.me), so this URL is intentionally not under the API base. It's built to be embedded in an <iframe> on your own page, and needs camera and microphone access for the KYC step — delegate them to the frame:

html
<iframe
  src="https://privy-stag.zay.me/onboarding?merchant_id=YOUR_MERCHANT_ID&user_id=USER_ID"
  allow="camera; microphone"
  style="border: 0; width: 100%; height: 100%;"
></iframe>

Query parameters

FieldRequiredDescription
merchant_idrequiredYour merchant id (the same value you send as zay-api-key). An unknown id returns 404.
user_idrequiredThe user's external id, as returned by POST /users. Must belong to this merchant; an unknown id returns 404.
callback_urloptionalWhere the widget redirects the user once they reach a terminal state. Only used when the widget runs as a standalone tab (not framed). https and custom app deep-link schemes are accepted; javascript:, data:, vbscript:, and file: URLs are rejected.

The user's phone_number and email (from POST /users) are pre-filled into the login and email steps, so they don't retype them.

The entry URL is not a secret. Security rests on the phone one-time code the user enters inside the widget, not on the URL — a merchant_id / user_id pair alone cannot complete onboarding for someone.

Knowing when onboarding finishes

Don't poll — subscribe to the user events webhooks. Onboarding includes an identity-verification (KYC) step, so the user.kyc_* events fire along the way, before a terminal onboarding event lands. In flow order:

  • user.onboarding_started — the user entered the widget.
  • user.kyc_submitteduser.kyc_passed / user.kyc_failed — KYC progresses and resolves. Onboarding always runs the user through KYC, so expect these en route.
  • user.onboarding_completedterminal success: wallet provisioned, card issued, account_stateactive. The user can now receive payouts. Carries issuer_wallet_address.

user.onboarding_completed is the event to act on — it's the one that tells you a user is payout-eligible. user.onboarding_started and the user.kyc_* events are optional, finer-grained progress signals — wire them up if you surface onboarding progress to your users. There is no terminal onboarding-failure event: a user who doesn't finish simply stays in account_state onboarding (their KYC and card steps can be retried), so detect a stalled onboarding by reading account_state via GET /users/{id}.


Payouts

Report a payout

POST /merchants/v1/payouts/report

Report a payout for a USDC transfer you have already broadcast on-chain, passing its transaction_hash. The payout is created in pending. Zay then verifies that transaction on-chain and, once confirmed, moves the payout to received and emits a payout.received webhook (or payout.failed if it can't be confirmed). There is nothing else to report back — Zay determines the outcome from the chain.

Body parameters

FieldTypeRequiredDescription
user_iduuidrequiredOwning user's external identifier
payout.amountstringrequiredDecimal amount as a string. Must be > 0 and <= 10000
payout.transaction_hashstringrequiredOn-chain transaction hash of the transfer you broadcast
payout.currency_codestringoptionalCurrency. Currently only USDC is supported. Defaults to USDC
payout.custom_metadataobjectoptionalFree-form JSON metadata to attach to the payout

Tip: always send a unique Idempotency-Key here. Network blips on payout creation should never silently double-record.

Errors

  • 401 — invalid signature.
  • 404user_id does not match a user under this merchant.
  • 422 — missing/blank transaction_hash, invalid amount (≤ 0 or > 10 000), or unsupported currency.
  • 429 — rate limit exceeded.

Examples

cURL
bash
curl -X POST "https://api-connect.zay.me/merchants/v1/payouts/report" \
  -H "Content-Type: application/json" \
  -H "zay-api-key: $ZAY_API_KEY" \
  -H "zay-timestamp: $TS" \
  -H "zay-signature: $SIG" \
  -H 'Idempotency-Key: "60eb2155-c3b4-43d7-9451-0fe1cc009d85"' \
  -d '{
    "user_id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
    "payout": {
      "amount": "1.23",
      "currency_code": "USDC",
      "transaction_hash": "0xabc123",
      "custom_metadata": {"invoice_ref": "INV-42"}
    }
  }'
Python
python
import json
import uuid

import requests

body = json.dumps({
    "user_id": user_id,
    "payout": {"amount": "1.23", "currency_code": "USDC", "transaction_hash": "0xabc123", "custom_metadata": {"invoice_ref": "INV-42"}},
})
url = "https://api-connect.zay.me/merchants/v1/payouts/report"
r = requests.post(url, data=body, headers={
    "Content-Type": "application/json",
    "zay-api-key": ZAY_API_KEY,
    "Idempotency-Key": f'"{uuid.uuid4()}"',
    **merchant_headers("POST", url, ZAY_PRIVATE_KEY, body),
})
payout = r.json()["payout"]
Node.js
javascript
const url = "https://api-connect.zay.me/merchants/v1/payouts/report";
const body = JSON.stringify({
  user_id: userId,
  payout: { amount: "1.23", currency_code: "USDC", transaction_hash: "0xabc123", custom_metadata: { invoice_ref: "INV-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 { payout } = await res.json();

Response · 201

json
{
  "payout": {
    "id": "eac4cfcc-97ee-4509-bb10-c6953bf30f9d",
    "user_id": "0f10952a-f715-4d89-a9d0-4da3dd6deabd",
    "amount": "1.23",
    "currency_code": "USDC",
    "status": "pending",
    "transaction_hash": "0xabc123",
    "reason": null,
    "custom_metadata": {"invoice_ref": "INV-42"},
    "created_at": "2025-12-05T12:05:49Z"
  }
}

Retrieve payout

GET /merchants/v1/payouts/{id}

Retrieve a payout by its external identifier, including its current status.

Path parameters

FieldTypeDescription
iduuidThe payout identifier

Errors

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

Examples

cURL
bash
curl "https://api-connect.zay.me/merchants/v1/payouts/$PAYOUT_ID" \
  -H "zay-api-key: $ZAY_API_KEY" \
  -H "zay-timestamp: $TS" \
  -H "zay-signature: $SIG"

Response · 200

json
{
  "payout": {
    "id": "60eb2155-c3b4-43d7-9451-0fe1cc009d85",
    "user_id": "b91a14b6-58ba-46d4-abb9-bc7dff4ea9cc",
    "amount": "12.3",
    "currency_code": "USDC",
    "status": "pending",
    "transaction_hash": "0xabc123",
    "reason": null,
    "custom_metadata": {},
    "created_at": "2025-12-05T12:05:49Z"
  }
}

List payouts

GET /merchants/v1/payouts

List payouts under your merchant with cursor pagination and optional filters. Results are ordered by descending creation time.

Query parameters

FieldTypeDescription
user_iduuidFilter by user external identifier
statusenumOne of pending, received, expired
created_fromdateInclusive lower bound (ISO-8601 date, e.g. 2025-12-01)
created_todateInclusive upper bound (ISO-8601 date, e.g. 2025-12-31)
page_sizeintItems per page. Range 1..1000, default 1000
page_cursorstringOpaque cursor from a previous response's next_cursor

When next_cursor is null or absent, the list is exhausted.

Errors

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

Examples

cURL
bash
curl "https://api-connect.zay.me/merchants/v1/payouts?user_id=$USER_ID&status=pending&page_size=20" \
  -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/payouts"
params = {"user_id": user_id, "status": "pending", "page_size": 20}
r = requests.get(url, params=params, headers={
    "zay-api-key": ZAY_API_KEY,
    **merchant_headers("GET", url, ZAY_PRIVATE_KEY),
})
data = r.json()
payouts = data["payouts"]
next_cursor = data.get("next_cursor")
Node.js
javascript
const url = new URL("https://api-connect.zay.me/merchants/v1/payouts");
url.searchParams.set("user_id", userId);
url.searchParams.set("status", "pending");
url.searchParams.set("page_size", "20");
const res = await fetch(url, {
  headers: {
    "zay-api-key": ZAY_API_KEY,
    ...merchantHeaders("GET", url.toString(), ZAY_PRIVATE_KEY),
  },
});
const { payouts, next_cursor: nextCursor } = await res.json();

Response · 200

json
{
  "payouts": [
    {
      "id": "3cf274cf-3816-4d23-a02f-3951cf8ba7e7",
      "user_id": "1de4f4eb-01ce-4ba1-b2a8-f788888c0059",
      "amount": "100.0",
      "currency_code": "USDC",
      "status": "pending",
      "transaction_hash": "0xabc123",
      "reason": null,
      "custom_metadata": {},
      "created_at": "2025-12-05T12:05:49Z"
    },
    {
      "id": "a540138b-467a-4bb5-8159-3ff73cef43cc",
      "user_id": "1de4f4eb-01ce-4ba1-b2a8-f788888c0059",
      "amount": "200.0",
      "currency_code": "USDC",
      "status": "received",
      "transaction_hash": "0xabc123",
      "reason": null,
      "custom_metadata": {"invoice_ref": "INV-7"},
      "created_at": "2025-12-05T12:05:49Z"
    }
  ],
  "next_cursor": "Ijk0ZjZhYjA1LTIxZTEtNDMxYS1hMzY4LWU2MmY1NDc1M2RmZiI=--f0aba3c8e2f7b8970dff0121c4626caaaa1985ba"
}

Events

Send your own server-side events to Zay in batches — a server-to-server channel for events your backend observes (for example, attribution or product events you want Zay to ingest). This is the inbound counterpart to webhook endpoints: there Zay POSTs events to you; here you POST events to Zay.

Submit events

POST /merchants/v1/events

Submit a batch of up to 1,000 events in one request. Authenticated with the standard HMAC scheme. The batch is accepted for asynchronous processing — a 202 means it was queued, not that every event has been processed. Validation is all-or-nothing: if any event is malformed, the whole batch is rejected and nothing is queued.

Body parameters

A single object with an events array:

FieldTypeRequiredDescription
eventsarrayrequiredThe events to ingest. 1–1,000 items per request.

Each item of events:

FieldTypeRequiredDescription
event_namestringrequiredThe event name. Case-insensitive.
payloadobjectrequiredFree-form JSON object of event properties.
event_datetimestringrequiredWhen the event occurred on your server. ISO-8601, UTC.
event_iduuid (v4)requiredUnique UUIDv4, used for idempotency (see below).
user_external_idstringrequiredYour own id for the user — the external_id you set on POST /users. It's your id, so you can send events before the user exists in Zay; events with the same user_external_id from before and after signup assemble into one journey.

Idempotency and limits

  • Per-event dedup. event_id deduplicates individual events: re-submitting an event whose event_id Zay has already seen is a no-op, so retrying a whole batch never double-counts. Use a fresh UUIDv4 per logical event.
  • Batch size. Up to 1,000 events per request, and the total request body must stay under 1 MB. Exceeding either limit returns 413.
  • A request-level Idempotency-Key header (see Idempotency) is also honored, but per-event event_id is the primary safeguard against double-counting on batch retries.

Errors

  • 400 — malformed JSON, or an event failed validation (missing/blank event_name, payload, event_datetime, event_id, or user_external_id; malformed event_id UUID; unparseable event_datetime).
  • 401 — invalid signature.
  • 413 — more than 1,000 events, or request body over 1 MB.
  • 429 — rate limit exceeded.

Example

cURL
bash
curl -X POST "https://api-connect.zay.me/merchants/v1/events" \
  -H "Content-Type: application/json" \
  -H "zay-api-key: $ZAY_API_KEY" \
  -H "zay-timestamp: $TS" \
  -H "zay-signature: $SIG" \
  -H 'Idempotency-Key: "b3c1e0a2-8f4d-4a1e-9c2b-1f6a2d5e7c90"' \
  -d '{
    "events": [
      {
        "event_name": "signup_completed",
        "payload": {"plan": "pro", "referrer": "campaign-42"},
        "event_datetime": "2025-12-05T12:05:49Z",
        "event_id": "0192f5b0-1234-7e8d-89ab-cdef01234567",
        "user_external_id": "acme-user-42"
      }
    ]
  }'

Response · 202

202 Accepted

The batch was accepted and queued for processing. The response has no body and carries no per-event results — a batch either validates and is queued in full, or is rejected whole (see the error codes above).


Webhook endpoints

Receive events from Zay by registering one or more HTTPS URLs we should POST to. Each delivery is signed so you can verify it actually came from us and hasn't been tampered with in transit.

This is a one-way channel — Zay → you. Webhook deliveries use a different signing scheme than the Merchant API itself (outbound deliveries follow Standard Webhooks so you can drop in a ready-made SDK), but the same zay-api-key / zay-signature HMAC scheme applies when you manage endpoints via this API.


The WebhookEndpoint object

Represents a single URL we'll POST to. A merchant can have up to 10 webhook endpoints.

FieldTypeDescription
iduuidStable identifier
urlstringHTTPS URL Zay will POST to. Validated against an SSRF blocklist at registration (loopback, RFC1918, link-local, etc. are rejected). http:// is allowed only against the local dev environment
descriptionstring | nullFree-form label, ≤ 255 chars
statusenumenabled or disabled. New endpoints start enabled. Set to disabled automatically when sustained delivery failures trip our autodisable policy — see Endpoint disabling
created_atdatetimeISO-8601, UTC
updated_atdatetimeISO-8601, UTC

The signing secret

A signing_secret is generated server-side at registration. It is returned exactly once — in the body of the POST /merchants/v1/webhook_endpoints response. We cannot show it to you again. Store it immediately in your secret manager. If you lose it, delete the endpoint and re-create it.

Secrets are prefixed with whsec_ (matching the Standard Webhooks convention) so they're easy to identify and grep for in logs.

Example

json
{
  "id": "0192f5a4-7c48-4fe9-b957-738bd2e04adc",
  "url": "https://merchant.example.com/zay-webhooks",
  "description": "Production endpoint",
  "status": "enabled",
  "created_at": "2025-12-05T12:05:49Z",
  "updated_at": "2025-12-05T12:05:49Z"
}

Outbound delivery format

Zay POSTs the event to your url. The request always carries these headers:

HeaderValue
content-typeapplication/json
user-agentZay-Webhooks/1.0
webhook-idUUID for this delivery. Stable across retries — use it for receiver-side deduplication
webhook-timestampUnix seconds at signing time
webhook-signatureSpace-separated list of vN,<base64> entries. Today only v1 is emitted. The list shape leaves room for future secret rotation

The body is a JSON envelope:

json
{
  "id": "0192f5b0-1234-7e8d-89ab-cdef01234567",
  "type": "webhook_endpoint.test",
  "occurred_at": "2025-12-05T12:05:49Z",
  "merchant_id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
  "data": {
    "webhook_endpoint_id": "0192f5a4-7c48-4fe9-b957-738bd2e04adc",
    "message": "This is a test event from Zay. If you received this, your endpoint is configured correctly."
  }
}

id is identical to the webhook-id header — either field works for deduplication. type identifies the event schema; data carries the type-specific payload. See the Event catalog for every type we emit and its data shape. Today the synthetic webhook_endpoint.test event is live; the business events in the catalog are rolling out, each independently and additively.

Timeouts and retries

We wait up to 30 seconds for your response. Anything outside 2xx (or a network/timeout failure) is treated as a delivery failure and we'll retry on the Standard Webhooks schedule:

0s → 5s → 5m → 30m → 2h → 5h → 10h → 14h → 20h → 24h

— total span of about three days, with ±10% jitter on each delay. We honor Retry-After on 429 and 503 (capped at 24h).

Status codes are handled per the Standard Webhooks spec:

You returnWhat Zay does
2xxSuccess. Delivery marked succeeded
3xxFailure. We do not follow redirects — update your url instead
4xx (not 408 / 429)Permanent failure. Delivery marked exhausted, no retries
408 / 429Retried per the schedule above
410 GoneDelivery marked exhausted and the endpoint is immediately set to disabled. Use this when you want us to stop delivering to a URL right away
5xxRetried per the schedule above

Event catalog

The delivery's type field names the event. Names follow one convention: <entity>.<event>, where entity is the API resource the event is about (user, payout, webhook_endpoint) and event is a past-tense, snake_case description of what happened — e.g. user.onboarding_completed, payout.received.

New event types are added over time, and adding one is always backward-compatible. Treat any type you don't recognise as a no-op and ignore it rather than erroring — that way new events never break your receiver.

Rollout status. webhook_endpoint.test is live today. The user.* and payout.* events below are rolling out — each ships independently and additively. Build against the envelope and subscribe to the events you care about as they land.

User events

Fired as a user moves through onboarding. Every payload carries user_id — the user's external identifier, the same id you received from POST /users.

typeFired whendata
user.onboarding_startedThe user begins onboarding in the widget or appuser_id
user.kyc_submittedKYC documents submitted; identity check under reviewuser_id
user.kyc_passedKYC approveduser_id
user.kyc_failedKYC rejecteduser_id, reason
user.onboarding_completedWallet provisioned and card issued — the user is now payout-eligible (account_stateactive)user_id, issuer_wallet_address

There is no terminal onboarding-failure event. A user who doesn't finish onboarding stays in account_state onboarding (KYC and card steps can be retried), so detect a stalled onboarding by reading account_state via GET /users/{id}, and rely on user.kyc_failed for KYC rejections.

user.onboarding_completed is the one event you must act on to know a user is ready for payouts. The user.kyc_* and user.onboarding_started events are optional, finer-grained progress signals — subscribe to them only if you surface onboarding progress to your creators.

Example — user.onboarding_completed:

json
{
  "id": "0192f5b0-1234-7e8d-89ab-cdef01234567",
  "type": "user.onboarding_completed",
  "occurred_at": "2025-12-05T12:05:49Z",
  "merchant_id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
  "data": {
    "user_id": "8775c532-9571-4c70-8bcd-8a822b9a0d9a",
    "issuer_wallet_address": "0x742d35cc6634c0532925a3b844bc9e7595f0beb7"
  }
}

Payout events

Fired when Zay resolves a payout against the chain — either confirmed (payout.received) or not (payout.failed). Each carries enough of the payout to act on without a follow-up GET.

typeFired whendata
payout.receivedZay confirms the payout's USDC transfer on-chain (pendingreceived)payout_id, user_id, amount, currency_code, transaction_hash, custom_metadata
payout.failedZay could not confirm the transfer; the payout expired (pendingexpired)payout_id, user_id, amount, currency_code, reason, custom_metadata

payout.received tells you the funds landed on-chain; payout.failed tells you they did not. The payout.failed name is intentionally generic — today it only fires on expired, but future failure causes will reuse it with a different reason. Correlate either to the payout you created via payout_id.

Payout reporting is not delivered as an event — you call POST /payouts/report yourself and already have the result synchronously.

Example — payout.received:

json
{
  "id": "0192f5b0-1234-7e8d-89ab-cdef01234567",
  "type": "payout.received",
  "occurred_at": "2025-12-05T12:05:49Z",
  "merchant_id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
  "data": {
    "payout_id": "60eb2155-c3b4-43d7-9451-0fe1cc009d85",
    "user_id": "8775c532-9571-4c70-8bcd-8a822b9a0d9a",
    "amount": "100.0",
    "currency_code": "USDC",
    "transaction_hash": "0xabc123",
    "custom_metadata": {"invoice_ref": "INV-42"}
  }
}

Example — payout.failed:

json
{
  "id": "0192f5b0-90ab-7cde-89ab-cdef01234567",
  "type": "payout.failed",
  "occurred_at": "2025-12-05T12:05:49Z",
  "merchant_id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
  "data": {
    "payout_id": "60eb2155-c3b4-43d7-9451-0fe1cc009d85",
    "user_id": "8775c532-9571-4c70-8bcd-8a822b9a0d9a",
    "amount": "100.0",
    "currency_code": "USDC",
    "reason": "on-chain transfer not confirmed within the verification window",
    "custom_metadata": {"invoice_ref": "INV-42"}
  }
}

Operational events

typeFired whendata
webhook_endpoint.testYou call Trigger test deliverywebhook_endpoint_id, message

Ordering and delivery guarantees

  • Ordering is not guaranteed. Two events about the same user or payout can arrive out of order (e.g. a retry of an earlier event lands after a later one). Treat each event as a statement about state at its occurred_at; when in doubt, re-read the resource via the API rather than trusting arrival order.
  • At-least-once delivery. The same event may arrive more than once. Dedupe on the webhook-id header / envelope id — see Verifying a webhook.
  • No replay while disabled. Events that occur while an endpoint is disabled are dropped for that endpoint — see Endpoint disabling.

Verifying a webhook

Verify every incoming delivery before acting on it. A correctly-signed request with a fresh timestamp proves the body wasn't tampered with and the call came from someone who knows your signing_secret.

Standard Webhooks ships verified SDKs for Ruby, Python, Node.js, Go, PHP, and others. Each is a one-liner:

Ruby
ruby
require "standardwebhooks"

wh = StandardWebhooks::Webhook.new(ENV["ZAY_WEBHOOK_SIGNING_SECRET"])
payload = wh.verify(request.raw_post, request.headers)
# payload is the parsed JSON body — safe to act on
Python
python
from standardwebhooks import Webhook

wh = Webhook(os.environ["ZAY_WEBHOOK_SIGNING_SECRET"])
payload = wh.verify(request_body_bytes, request_headers)
Node.js
javascript
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.ZAY_WEBHOOK_SIGNING_SECRET);
const payload = wh.verify(requestBody, requestHeaders);

The SDK enforces all the rules in the next section for you, including the 5-minute timestamp window and constant-time signature comparison.

Verifying manually (when an SDK is not an option)

If you'd rather not add a dependency, the algorithm is straightforward:

  1. Read the raw request body as bytes — do not parse and re-serialise it. Whitespace and key ordering matter for the signature to verify.
  2. Read the headers webhook-id, webhook-timestamp, and webhook-signature. Reject the request with 400 if any are missing.
  3. Check freshness: reject if webhook-timestamp is more than ±5 minutes from your current time (replay-attack protection).
  4. Build the string-to-sign by concatenating, with literal dots:
    {webhook-id}.{webhook-timestamp}.{raw body}
  5. Derive the HMAC key: strip the whsec_ prefix from your stored secret, then base64-decode the remainder. This gives you the raw key bytes.
  6. Compute HMAC-SHA256(key, string-to-sign).
  7. Base64-encode the digest using the standard alphabet (with padding).
  8. webhook-signature is a space-separated list of vN,<base64> entries (one per active signing key, for future rotation). Split on whitespace and look for any entry where the version is v1 and the base64 part matches your computed digest. Use a constant-time comparison — a naïve == leaks timing information.
  9. Dedupe by webhook-id on your side (e.g. insert into a table with a unique index, or check a cache). Zay re-sends the same webhook-id on every retry of the same delivery.

If the comparison succeeds, parse the body as JSON and act on it. Otherwise, reject with 400.


Endpoint disabling

Zay automatically disables a webhook endpoint that fails consistently. This protects both sides: your servers from being hammered while broken, and our delivery pipeline from burning retries on a dead URL.

What counts as a failure

  • Any HTTP response outside 2xx (including 3xx redirects — we don't follow them)
  • Network errors (connection refused, DNS failure, TLS handshake failure)
  • Request timeouts (we wait up to 30 seconds for a response)

When we disable

We track failures per endpoint within a rolling window of several hours. An endpoint that fails repeatedly within that window is set to status: disabled. A single intermittent failure or a one-off error will not disable your endpoint — only a sustained pattern will.

A few things worth knowing:

  • Retries of the same event count toward the rate. A single event that goes through its full retry curve and never succeeds contributes ~9 failures on its own.
  • Successful deliveries between failures do not reset any counter — we look at the failure rate over the window, not consecutive failures. This catches flapping endpoints that would otherwise slip through.
  • Receiving HTTP 410 Gone from your endpoint disables it immediately. Use this status code if you want us to stop delivering to a URL right away.

How to tell your endpoint is disabled

Call GET /merchants/v1/webhook_endpoints/{id}status reads disabled.

While disabled, no new deliveries are queued for that endpoint. Real-time business events that happen during the disabled period are dropped for this endpoint specifically. (Other enabled endpoints on your account continue to receive them.) There is no buffer-and-replay today; we'll publish a replay API when that becomes a priority.

Re-enabling

After you've fixed the issue on your side, re-enable the endpoint by updating its status:

PATCH /merchants/v1/webhook_endpoints/{id}
{ "webhook_endpoint": { "status": "enabled" } }

Then send yourself a test delivery to confirm it actually works before the next real event hits:

POST /merchants/v1/webhook_endpoints/{id}/test_delivery

The internal failure counter decays over time, so by the time you re-enable, a healthy endpoint starts fresh.


Register webhook endpoint

POST /merchants/v1/webhook_endpoints

Register a new HTTPS URL to receive webhook deliveries. Returns the endpoint with its signing_secret — this is the only response that ever carries the secret. Store it immediately.

Body parameters

The body is wrapped in webhook_endpoint:

FieldTypeRequiredDescription
urlstringrequiredHTTPS URL Zay will POST to. Must resolve to a public IP — loopback / RFC1918 / link-local addresses are rejected with 422
descriptionstring | nulloptionalFree-form label, ≤ 255 chars. Useful when you have several endpoints for different environments

Returns

201 Created with a wrapped webhook_endpoint that includes signing_secret.

Errors

  • 401 — invalid signature.
  • 422 — URL fails SSRF validation, or you already have 10 endpoints.
  • 429 — rate limit exceeded.

Examples

cURL
bash
curl -X POST "https://api-connect.zay.me/merchants/v1/webhook_endpoints" \
  -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 '{
    "webhook_endpoint": {
      "url": "https://merchant.example.com/zay-webhooks",
      "description": "Production endpoint"
    }
  }'
Python
python
body = json.dumps({
    "webhook_endpoint": {
        "url": "https://merchant.example.com/zay-webhooks",
        "description": "Production endpoint",
    }
})
url = "https://api-connect.zay.me/merchants/v1/webhook_endpoints"
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)
endpoint = r.json()["webhook_endpoint"]
signing_secret = endpoint["signing_secret"]  # store this immediately
Node.js
javascript
const url = "https://api-connect.zay.me/merchants/v1/webhook_endpoints";
const body = JSON.stringify({
  webhook_endpoint: {
    url: "https://merchant.example.com/zay-webhooks",
    description: "Production endpoint",
  },
});
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 { webhook_endpoint: endpoint } = await res.json();
// endpoint.signing_secret — store immediately, you won't see it again

Response · 201

json
{
  "webhook_endpoint": {
    "id": "0192f5a4-7c48-4fe9-b957-738bd2e04adc",
    "url": "https://merchant.example.com/zay-webhooks",
    "description": "Production endpoint",
    "status": "enabled",
    "created_at": "2025-12-05T12:05:49Z",
    "updated_at": "2025-12-05T12:05:49Z",
    "signing_secret": "whsec_DGVzdHRlc3R0ZXN0dGVzdHRlc3R0ZXN0dGVzdHRlc3Q="
  }
}

List webhook endpoints

GET /merchants/v1/webhook_endpoints

Returns all webhook endpoints registered under your merchant account. No pagination — the cap is 10. signing_secret is never included in this response.

Errors

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

Example

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

Response · 200

json
{
  "webhook_endpoints": [
    {
      "id": "0192f5a4-7c48-4fe9-b957-738bd2e04adc",
      "url": "https://merchant.example.com/zay-webhooks",
      "description": "Production endpoint",
      "status": "enabled",
      "created_at": "2025-12-05T12:05:49Z",
      "updated_at": "2025-12-05T12:05:49Z"
    }
  ]
}

Retrieve webhook endpoint

GET /merchants/v1/webhook_endpoints/{id}

Returns a single webhook endpoint by id. signing_secret is not included.

Path parameters

FieldTypeDescription
iduuidThe webhook endpoint id

Errors

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

Example

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

Update webhook endpoint

PATCH /merchants/v1/webhook_endpoints/{id}

Update one or more of url, description, status. signing_secret cannot be changed via this endpoint — to rotate a secret you must delete the endpoint and register a new one.

Body parameters

The body is wrapped in webhook_endpoint. All fields are optional:

FieldTypeDescription
urlstringNew HTTPS URL. Validated against the SSRF blocklist
descriptionstring | nullNew label
statusenumenabled or disabled. Use this to re-enable an auto-disabled endpoint

Errors

  • 401 — invalid signature.
  • 404 — endpoint not found.
  • 422 — URL fails SSRF validation.
  • 429 — rate limit exceeded.

Example — re-enable an auto-disabled endpoint

bash
curl -X PATCH "https://api-connect.zay.me/merchants/v1/webhook_endpoints/0192f5a4-7c48-4fe9-b957-738bd2e04adc" \
  -H "Content-Type: application/json" \
  -H "zay-api-key: $ZAY_API_KEY" \
  -H "zay-timestamp: $TS" \
  -H "zay-signature: $SIG" \
  -d '{ "webhook_endpoint": { "status": "enabled" } }'

Delete webhook endpoint

DELETE /merchants/v1/webhook_endpoints/{id}

Delete a webhook endpoint. Any in-flight deliveries scheduled for this endpoint are dropped — there is no graceful drain.

Errors

  • 401 — invalid signature.
  • 404 — endpoint not found.
  • 429 — rate limit exceeded.

Example

bash
curl -X DELETE "https://api-connect.zay.me/merchants/v1/webhook_endpoints/0192f5a4-7c48-4fe9-b957-738bd2e04adc" \
  -H "zay-api-key: $ZAY_API_KEY" \
  -H "zay-timestamp: $TS" \
  -H "zay-signature: $SIG"

204 No Content on success.


Trigger test delivery

POST /merchants/v1/webhook_endpoints/{id}/test_delivery

Send a synthetic webhook_endpoint.test event through the full signed-delivery pipeline. Useful right after registration (or right after re-enabling) to confirm your receiver is wired up before real events flow.

Two things worth knowing:

  • The endpoint does not need to be enabled for test_delivery to fire — that's intentional, so you can verify the URL works while the endpoint is still in disabled state.
  • The synchronous 202 response carries the id of the delivery. That same id will appear in the webhook-id header of the async POST your receiver gets — handy for correlating the trigger call to the inbound delivery in your logs.

Path parameters

FieldTypeDescription
iduuidThe webhook endpoint id

Errors

  • 401 — invalid signature.
  • 404 — endpoint not found.
  • 429 — rate limit exceeded.

Example

bash
curl -X POST "https://api-connect.zay.me/merchants/v1/webhook_endpoints/0192f5a4-7c48-4fe9-b957-738bd2e04adc/test_delivery" \
  -H "zay-api-key: $ZAY_API_KEY" \
  -H "zay-timestamp: $TS" \
  -H "zay-signature: $SIG"

Response · 202

json
{
  "webhook_delivery": {
    "id": "0192f5b0-1234-7e8d-89ab-cdef01234567",
    "type": "webhook_endpoint.test",
    "occurred_at": "2025-12-05T12:05:49Z",
    "data": {
      "webhook_endpoint_id": "0192f5a4-7c48-4fe9-b957-738bd2e04adc",
      "message": "This is a test event from Zay. If you received this, your endpoint is configured correctly."
    }
  }
}

A few seconds later, your endpoint receives the actual HTTP POST with the headers and body described in Outbound delivery format.


Quick start

The happy path: create user → onboard → broadcast on-chain → record payout → receive payout.received.

  1. POST /merchants/v1/users — create the merchant-side user; they start in onboarding
  2. Send them through onboarding and wait for the user.onboarding_completed webhook — now they're payout-eligible
  3. Broadcast the USDC transfer to the user on-chain yourself
  4. When the broadcast resolves (success or failure), come back to Zay
  5. If it succeeded, POST /merchants/v1/payouts/report — report the payout; it lands in pending
  6. Zay verifies the transfer on-chain and sends a webhook: payout.received once confirmed (status received), or payout.failed if it can't be confirmed (status expired)

Copy-paste cURL template

bash
curl -X POST "https://api-connect.zay.me/merchants/v1/payouts/report" \
  -H "Content-Type: application/json" \
  -H "zay-api-key: $ZAY_API_KEY" \
  -H "zay-timestamp: $TS" \
  -H "zay-signature: $SIG" \
  -H 'Idempotency-Key: "<uuid>"' \
  -d '{
    "user_id": "2c9596e6-7c48-4fe9-b957-738bd2e04adc",
    "payout": { "amount": "100.0", "currency_code": "USDC", "transaction_hash": "0xabc123" }
  }'

ZAY · Merchant API · v1