Appearance
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.receivedwebhook - 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_deliveryRequired 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:
timestamp— same value sent inzay-timestamp(unix seconds)method— HTTP verb in upper casepath— request path, e.g./merchants/v1/payoutsbody— 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-timestampmust be within ±5 minutes of server time, otherwise the request is rejected with401.
Headers reference
| Header | Type | Required | Purpose |
|---|---|---|---|
zay-api-key | string | required | Public merchant identifier (UUID) |
zay-timestamp | int | required | Unix timestamp in seconds |
zay-signature | string | required | Hex-encoded HMAC-SHA256 of the canonical payload |
Idempotency-Key | string | recommended | Quoted 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:
- Missing headers —
zay-timestamporzay-signaturewas not sent. - Timestamp drift —
zay-timestampis more than ±5 minutes from server time. - 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 0aTo 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 -ppython
# 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_bytesis 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-Keytogether withzay-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
| Status | Meaning |
|---|---|
400 | Bad request |
401 | Invalid zay-api-key or signature |
403 | Access forbidden |
404 | Resource not found |
409 | Idempotent retry in flight, or invalid status transition |
422 | Validation error in request body |
429 | Rate limit exceeded |
503 | Service 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.
| Field | Type | Description |
|---|---|---|
id | uuid | Stable identifier (UUID) |
external_id | string | null | Your 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_number | string | E.164 formatted phone number |
email | string | null | Merchant-provided email. Optional reference data, not used to match Zay accounts. null when not supplied |
created_at | datetime | ISO-8601, UTC |
account_state | enum | Account lifecycle state: one of onboarding, active, deactivating, deactivated. Stays onboarding until the user finishes signing up in Zay |
issuer_wallet_address | string | null | Issuer (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.
| Field | Type | Description |
|---|---|---|
id | uuid | Stable identifier (the payout's external identifier) |
user_id | uuid | Owning user (their external identifier) |
amount | string | Decimal amount serialised as a string. Must be > 0 and <= 10000 |
currency_code | string | Currency. Currently only USDC is supported |
status | enum | See payout statuses |
transaction_hash | string | On-chain transaction hash of the transfer the merchant broadcast, supplied when creating the payout |
reason | string | null | Free-text reason, set by Zay when a payout is expired. null otherwise |
custom_metadata | object | Free-form JSON bag the merchant attaches to the payout at creation |
created_at | datetime | ISO-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.
| Field | Type | Description |
|---|---|---|
confirmed | string | Sum of received payouts — on-chain-verified (decimal as string) |
pending | string | Sum of pending payouts — not yet verified (decimal as string) |
committed | string | confirmed + pending |
currency_code | string | Currency (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──→ expiredpending— 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 apayout.receivedwebhook.expired— terminal failure, set by Zay. The on-chain transfer was not confirmed within the verification window. Zay emits apayout.failedwebhook carrying areason.
received and expired are terminal — payouts cannot leave them.
Users
Create user
POST /merchants/v1/usersCreate a user under your merchant account. The new user starts in account_stateonboarding.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
phone_number | string | required | E.164 phone number |
email | string | optional | Reference email for the user. Not used to match Zay accounts |
external_id | string | optional | Your 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, 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": "[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
| Field | Type | Description |
|---|---|---|
id | uuid | The 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
| Field | Type | Required | Description |
|---|---|---|---|
address | string | required | The 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}/balanceReturns the user's current balance, computed live from their payouts under your merchant account.
Path parameters
| Field | Type | Description |
|---|---|---|
id | uuid | The 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
| Field | Required | Description |
|---|---|---|
merchant_id | required | Your merchant id (the same value you send as zay-api-key). An unknown id returns 404. |
user_id | required | The user's external id, as returned by POST /users. Must belong to this merchant; an unknown id returns 404. |
callback_url | optional | Where 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_idpair 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_submitted→user.kyc_passed/user.kyc_failed— KYC progresses and resolves. Onboarding always runs the user through KYC, so expect these en route.user.onboarding_completed— terminal success: wallet provisioned, card issued,account_state→active. The user can now receive payouts. Carriesissuer_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/reportReport 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
| Field | Type | Required | Description |
|---|---|---|---|
user_id | uuid | required | Owning user's external identifier |
payout.amount | string | required | Decimal amount as a string. Must be > 0 and <= 10000 |
payout.transaction_hash | string | required | On-chain transaction hash of the transfer you broadcast |
payout.currency_code | string | optional | Currency. Currently only USDC is supported. Defaults to USDC |
payout.custom_metadata | object | optional | Free-form JSON metadata to attach to the payout |
Tip: always send a unique
Idempotency-Keyhere. Network blips on payout creation should never silently double-record.
Errors
401— invalid signature.404—user_iddoes not match a user under this merchant.422— missing/blanktransaction_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
| Field | Type | Description |
|---|---|---|
id | uuid | The 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/payoutsList payouts under your merchant with cursor pagination and optional filters. Results are ordered by descending creation time.
Query parameters
| Field | Type | Description |
|---|---|---|
user_id | uuid | Filter by user external identifier |
status | enum | One of pending, received, expired |
created_from | date | Inclusive lower bound (ISO-8601 date, e.g. 2025-12-01) |
created_to | date | Inclusive upper bound (ISO-8601 date, e.g. 2025-12-31) |
page_size | int | Items per page. Range 1..1000, default 1000 |
page_cursor | string | Opaque 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/eventsSubmit 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:
| Field | Type | Required | Description |
|---|---|---|---|
events | array | required | The events to ingest. 1–1,000 items per request. |
Each item of events:
| Field | Type | Required | Description |
|---|---|---|---|
event_name | string | required | The event name. Case-insensitive. |
payload | object | required | Free-form JSON object of event properties. |
event_datetime | string | required | When the event occurred on your server. ISO-8601, UTC. |
event_id | uuid (v4) | required | Unique UUIDv4, used for idempotency (see below). |
user_external_id | string | required | Your 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_iddeduplicates individual events: re-submitting an event whoseevent_idZay 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-Keyheader (see Idempotency) is also honored, but per-eventevent_idis the primary safeguard against double-counting on batch retries.
Errors
400— malformed JSON, or an event failed validation (missing/blankevent_name,payload,event_datetime,event_id, oruser_external_id; malformedevent_idUUID; unparseableevent_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 AcceptedThe 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.
| Field | Type | Description |
|---|---|---|
id | uuid | Stable identifier |
url | string | HTTPS 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 |
description | string | null | Free-form label, ≤ 255 chars |
status | enum | enabled or disabled. New endpoints start enabled. Set to disabled automatically when sustained delivery failures trip our autodisable policy — see Endpoint disabling |
created_at | datetime | ISO-8601, UTC |
updated_at | datetime | ISO-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:
| Header | Value |
|---|---|
content-type | application/json |
user-agent | Zay-Webhooks/1.0 |
webhook-id | UUID for this delivery. Stable across retries — use it for receiver-side deduplication |
webhook-timestamp | Unix seconds at signing time |
webhook-signature | Space-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 return | What Zay does |
|---|---|
2xx | Success. Delivery marked succeeded |
3xx | Failure. We do not follow redirects — update your url instead |
4xx (not 408 / 429) | Permanent failure. Delivery marked exhausted, no retries |
408 / 429 | Retried per the schedule above |
410 Gone | Delivery marked exhausted and the endpoint is immediately set to disabled. Use this when you want us to stop delivering to a URL right away |
5xx | Retried 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.testis live today. Theuser.*andpayout.*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.
type | Fired when | data |
|---|---|---|
user.onboarding_started | The user begins onboarding in the widget or app | user_id |
user.kyc_submitted | KYC documents submitted; identity check under review | user_id |
user.kyc_passed | KYC approved | user_id |
user.kyc_failed | KYC rejected | user_id, reason |
user.onboarding_completed | Wallet provisioned and card issued — the user is now payout-eligible (account_state → active) | 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.
type | Fired when | data |
|---|---|---|
payout.received | Zay confirms the payout's USDC transfer on-chain (pending → received) | payout_id, user_id, amount, currency_code, transaction_hash, custom_metadata |
payout.failed | Zay could not confirm the transfer; the payout expired (pending → expired) | 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
type | Fired when | data |
|---|---|---|
webhook_endpoint.test | You call Trigger test delivery | webhook_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-idheader / envelopeid— see Verifying a webhook. - No replay while disabled. Events that occur while an endpoint is
disabledare 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.
Using a Standard Webhooks SDK (recommended)
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 onPython
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:
- Read the raw request body as bytes — do not parse and re-serialise it. Whitespace and key ordering matter for the signature to verify.
- Read the headers
webhook-id,webhook-timestamp, andwebhook-signature. Reject the request with400if any are missing. - Check freshness: reject if
webhook-timestampis more than ±5 minutes from your current time (replay-attack protection). - Build the string-to-sign by concatenating, with literal dots:
{webhook-id}.{webhook-timestamp}.{raw body} - Derive the HMAC key: strip the
whsec_prefix from your stored secret, then base64-decode the remainder. This gives you the raw key bytes. - Compute
HMAC-SHA256(key, string-to-sign). - Base64-encode the digest using the standard alphabet (with padding).
webhook-signatureis a space-separated list ofvN,<base64>entries (one per active signing key, for future rotation). Split on whitespace and look for any entry where the version isv1and the base64 part matches your computed digest. Use a constant-time comparison — a naïve==leaks timing information.- Dedupe by
webhook-idon your side (e.g. insert into a table with a unique index, or check a cache). Zay re-sends the samewebhook-idon 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(including3xxredirects — 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 Gonefrom 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_deliveryThe 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_endpointsRegister 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:
| Field | Type | Required | Description |
|---|---|---|---|
url | string | required | HTTPS URL Zay will POST to. Must resolve to a public IP — loopback / RFC1918 / link-local addresses are rejected with 422 |
description | string | null | optional | Free-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 immediatelyNode.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 againResponse · 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_endpointsReturns 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
| Field | Type | Description |
|---|---|---|
id | uuid | The 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:
| Field | Type | Description |
|---|---|---|
url | string | New HTTPS URL. Validated against the SSRF blocklist |
description | string | null | New label |
status | enum | enabled 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_deliverySend 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
enabledfortest_deliveryto fire — that's intentional, so you can verify the URL works while the endpoint is still indisabledstate. - The synchronous
202response carries theidof the delivery. That sameidwill appear in thewebhook-idheader of the async POST your receiver gets — handy for correlating the trigger call to the inbound delivery in your logs.
Path parameters
| Field | Type | Description |
|---|---|---|
id | uuid | The 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.
- POST
/merchants/v1/users— create the merchant-side user; they start inonboarding - Send them through onboarding and wait for the
user.onboarding_completedwebhook — now they're payout-eligible - Broadcast the USDC transfer to the user on-chain yourself
- When the broadcast resolves (success or failure), come back to Zay
- If it succeeded, POST
/merchants/v1/payouts/report— report the payout; it lands inpending - Zay verifies the transfer on-chain and sends a webhook:
payout.receivedonce confirmed (statusreceived), orpayout.failedif it can't be confirmed (statusexpired)
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