ZyndPay API Reference
The ZyndPay API is a RESTful payment gateway for accepting payments across three rails: USDT on the TRON blockchain, bank card (XOF), and mobile money (XOF). Resource-oriented URLs, JSON bodies, and a consistent response envelope make it easy to integrate in any environment.
All amounts and fees are evaluated against the payment rail and merchant account configuration. The dashboard, checkout, API response and webhook payloads are the source of truth for the current effective fee applied to a transaction.
https://api.zyndpay.io/v1{
"success": true,
"data": { ... },
"meta": { "page": 1, "limit": 20, "total": 100 }
}Authentication
Include your API key in the X-Api-Key header on every request. Alternatively, pass it as a Bearer token in the Authorization header.
curl https://api.zyndpay.io/v1/payments \
-H "X-Api-Key: zyp_live_sk_..."| Parameter | Type | Required | Description |
|---|---|---|---|
zyp_live_sk_* | string | required | Live secret key — full API access. Never expose client-side. |
zyp_live_pk_* | string | optional | Live publishable key — limited read access, safe for browsers. |
zyp_test_sk_* | string | required | Sandbox secret key — identical to live but no real transactions. |
zyp_test_pk_* | string | optional | Sandbox publishable key. |
zyp_rk_* | string | optional | Restricted key — scoped to specific operations only. |
Quick Start
Accept your first USDT payment in under 5 minutes. Select your language below — each example shows install, create a pay-in, handle the webhook, and check status.
// 1. Install
// npm install @zyndpay/sdk
// 2. Initialize
import { ZyndPay } from '@zyndpay/sdk';
const zyndpay = new ZyndPay({
apiKey: process.env.ZYNDPAY_API_KEY!,
webhookSecret: process.env.ZYNDPAY_WEBHOOK_SECRET,
});
// 3. Create a pay-in and get a payment URL
const payin = await zyndpay.payins.create({
amount: '100',
externalRef: 'order_' + Date.now(), // your order ID
successUrl: 'https://yoursite.com/success',
cancelUrl: 'https://yoursite.com/cancel',
});
console.log('Redirect customer to:', payin.paymentUrl);
// Or show payin.address if you want to display the TRON address directly
// 4. Handle the webhook (Express.js)
import express from 'express';
const app = express();
app.post('/webhooks/zyndpay', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['zyndpay-signature'] as string;
let event;
try {
event = zyndpay.webhooks.verify(req.body, signature);
} catch {
return res.status(400).send('Invalid signature');
}
switch (event.event) {
case 'payin.confirmed':
// Payment received — fulfill the order
console.log('Paid:', event.data.externalRef, event.data.amount, 'USDT');
break;
case 'payin.expired':
console.log('Expired:', event.data.externalRef);
break;
case 'payin.underpaid':
console.log('Underpaid:', event.data.externalRef,
'got', event.data.amount, 'expected', event.data.amountRequested);
break;
}
res.json({ received: true });
});
// 5. Poll / check status programmatically
const tx = await zyndpay.payins.get(payin.transactionId);
console.log('Status:', tx.status); // "CONFIRMED"Going Live
Before accepting real payments, work through this checklist. Most items take under 5 minutes each.
Create a Payin
/v1/paymentsGenerates a unique TRON wallet address for your customer to send USDT to. The address expires after <ic>expiresInSeconds</ic> seconds (default 30 minutes).
| Parameter | Type | Required | Description |
|---|---|---|---|
amount | string | required | Amount in USDT as a decimal string (e.g. "100.00"). Minimum: "1.00". |
externalRef | string | optional | Your order or reference ID (optional). Must be unique per merchant if provided. |
expiresInSeconds | number | optional | Seconds until the address expires. Minimum: 900. Default: 1800 (30 minutes). |
successUrl | string | optional | URL to redirect the customer after a successful payment. |
cancelUrl | string | optional | URL to redirect the customer if the payment expires or is cancelled. |
metadata | object | optional | Arbitrary key-value pairs stored with the payment and included in webhooks. |
paymentMethod | string | optional | Payment rail. "USDT_TRC20" (default), "CARD", or "MOBILE_MONEY". |
customerName | string | optional | Customer full name. Required for card payments. |
customerEmail | string | optional | Customer email address. Required for card payments. |
curl -X POST https://api.zyndpay.io/v1/payments \
-H "X-Api-Key: zyp_live_sk_..." \
-H "Content-Type: application/json" \
-d '{
"amount": "100.00",
"externalRef": "order_123",
"expiresInSeconds": 3600,
"successUrl": "https://yoursite.com/payment/success",
"cancelUrl": "https://yoursite.com/payment/cancel"
}'{
"success": true,
"data": {
"transactionId": "pay_abc123...",
"address": "TRXabc123def456ghi789jkl",
"paymentUrl": "https://checkout.zyndpay.io/pay_abc123...",
"qrCodeUrl": "data:image/png;base64,iVBOR...",
"amount": "100.00",
"amountExpected": "101.00",
"networkFee": "1",
"currency": "USDT_TRC20",
"chain": "TRON",
"status": "AWAITING_PAYMENT",
"expiresAt": "2026-03-06T12:00:00.000Z"
}
}Mobile Money
/v1/paymentsCollect mobile money payments through a hosted ZyndPay checkout — your customer enters their OTP (Orange Money) or approves the push notification (Moov, MTN, Wave) on a ZyndPay-branded page. Supports XOF only.
| Parameter | Type | Required | Description |
|---|---|---|---|
paymentMethod | string | required | Must be "MOBILE_MONEY" for a mobile money pay-in. |
amount | string | required | Amount in XOF as a string (e.g. "1000"). XOF has no decimals — pass an integer-valued string. Minimum: "500". |
customerPhone | string | required | Customer phone number in E.164 format (e.g. "+22670123456"). Required for MOBILE_MONEY. |
operatorCode | string | optional | Override the mobile-money operator detected from the phone prefix. Must be one of ORANGE_BF, MOOV_BF. Ignored for non-MOBILE_MONEY rails. |
externalRef | string | optional | Your order or reference ID (optional). Must be unique per merchant if provided. |
metadata | object | optional | Arbitrary key-value pairs stored with the payment and included in webhooks. |
The operator is resolved server-side from the phone prefix. You can override it with <ic>operatorCode</ic> when the customer picks one explicitly (recommended). Supported codes: ORANGE_BF, MOOV_BF.
curl -X POST https://api.zyndpay.io/v1/payments \
-H "X-Api-Key: zyp_live_sk_..." \
-H "Content-Type: application/json" \
-d '{
"amount": "1000",
"paymentMethod": "MOBILE_MONEY",
"customerPhone": "+22670123456",
"customerName": "Aïcha Traoré",
"operatorCode": "ORANGE_BF",
"externalRef": "order_123"
}'The response includes <ic>hostedPaymentUrl</ic> — redirect your customer there to complete the payment. ZyndPay handles the OTP form, the operator instruction, the status polling, and the result page. The status starts as AWAITING_PAYMENT and transitions to CONFIRMED (or FAILED) via webhook.
If you'd rather drive the OTP flow yourself, the response also includes <ic>nextStep</ic> (<ic>"otp"</ic> or <ic>"approval"</ic>), <ic>operatorCode</ic>, and <ic>instruction</ic>. Render your own form, call Submit OTP for OTP-mode operators, and poll Get Payin. New integrations should prefer the hosted flow above.
The response's <ic>nextStep</ic> tells you what to do next: <ic>"otp"</ic> means your UI should prompt for the code the customer just received by SMS, then call Submit OTP. <ic>"approval"</ic> means the operator has pushed a confirmation request to the customer's phone — display the <ic>instruction</ic> string to tell the customer what to do (e.g. "Dial *555*6#"), then poll Get Payin until the status leaves AWAITING_PAYMENT.
{
"success": true,
"data": {
"transactionId": "pay_momo_abc123",
"paymentMethod": "MOBILE_MONEY",
"hostedPaymentUrl": "https://checkout.zyndpay.io/m/pay_momo_abc123",
"nextStep": "otp",
"operatorCode": "ORANGE_BF",
"instruction": {
"fr": "Vous allez recevoir un SMS avec votre code de validation. Saisissez-le ci-dessous.",
"en": "You will receive an SMS with your validation code. Enter it below."
},
"amount": "1000",
"currency": "XOF",
"status": "AWAITING_PAYMENT",
"expiresAt": "2026-03-06T12:05:00.000Z"
}
}{
"success": true,
"data": {
"transactionId": "pay_momo_xyz456",
"paymentMethod": "MOBILE_MONEY",
"hostedPaymentUrl": "https://checkout.zyndpay.io/m/pay_momo_xyz456",
"nextStep": "approval",
"operatorCode": "MOOV_BF",
"instruction": {
"fr": "Composez *555*6# sur votre téléphone pour valider le paiement.",
"en": "Dial *555*6# on your phone to approve the payment."
},
"amount": "1000",
"currency": "XOF",
"status": "AWAITING_PAYMENT",
"expiresAt": "2026-03-06T12:03:00.000Z"
}
}Submit OTP
/v1/payments/:id/submit-otp/v1/payins/:id/submit-otpSubmits the OTP your customer received by SMS on an OTP-mode MoMo rail (Orange BF). Returns immediately with status AWAITING_PAYMENT; the final CONFIRMED state lands via your webhook endpoint once the transaction has settled. Both <ic>POST /v1/payments/:id/submit-otp</ic> and <ic>POST /v1/payins/:id/submit-otp</ic> route to the same handler — the <ic>/payments</ic> form matches the rest of the payin lifecycle and is preferred for new integrations; <ic>/payins</ic> is preserved for existing callers.
| Parameter | Type | Required | Description |
|---|---|---|---|
otp | string | required | The 4-8 digit code the customer received by SMS. Only applies to OTP-mode operators. |
curl -X POST https://api.zyndpay.io/v1/payins/pay_momo_abc123/submit-otp \
-H "X-Api-Key: zyp_live_sk_..." \
-H "Content-Type: application/json" \
-d '{
"otp": "123456"
}'{
"success": true,
"data": {
"transactionId": "pay_momo_abc123",
"status": "AWAITING_PAYMENT"
}
}Get a Payin
/v1/payments/:id{
"success": true,
"data": {
"id": "f4b2cb0f-08ce-4408-88d4-0a678ca0aae2",
"address": "TRXabc123def456ghi789jkl",
"amount": "100",
"amountReceived": "100.00",
"zyndpayFee": "1.00",
"status": "CONFIRMED",
"txHash": "abc123def456...",
"externalRef": "order_123",
"currency": "USDT_TRC20",
"chain": "TRON",
"paymentUrl": "https://checkout.zyndpay.io/f4b2cb0f...",
"isSandbox": false,
"createdAt": "2026-03-06T11:00:00.000Z",
"updatedAt": "2026-03-06T11:01:02.000Z"
}
}AWAITING_PAYMENTInitial status — deposit address active, awaiting fundsPENDINGAddress generated, no funds received yetCONFIRMINGFunds received, waiting for 5 confirmationsCONFIRMED5+ confirmations — balance creditedUNDERPAIDLess than expected receivedOVERPAIDMore than expected receivedEXPIREDAddress expired without paymentFAILEDPayment failed — no funds receivedCard Payment
Accept XOF card payments by redirecting your customer to a hosted payment page. The customer completes payment in their browser; ZyndPay fires a payin.confirmed webhook when the card charge settles.
/v1/payments| Parameter | Type | Required | Description |
|---|---|---|---|
amount | string | required | Amount in XOF as a decimal string (e.g. "5000"). XOF has no decimals — pass an integer-valued string. Minimum: "100". The settlement currency is fixed to XOF — do not pass a `currency` field; the API rejects unknown properties. |
paymentMethod | string | required | Payment rail. "USDT_TRC20" (default), "CARD", or "MOBILE_MONEY". |
customerName | string | required | Customer full name. Required for card payments. |
customerEmail | string | required | Customer email address. Required for card payments. |
curl -X POST https://api.zyndpay.io/v1/payments \
-H "X-Api-Key: zyp_live_sk_..." \
-H "Content-Type: application/json" \
-d '{
"amount": "5000",
"paymentMethod": "CARD",
"customerName": "Kofi Mensah",
"customerEmail": "[email protected]",
"externalRef": "order_abc123"
}'const payin = await zyndpay.payins.create({
amount: '5000',
paymentMethod: 'CARD',
customerName: 'Kofi Mensah',
customerEmail: '[email protected]',
externalRef: 'order_abc123',
});
// Redirect customer to complete payment
window.location.href = payin.hostedPaymentUrl;Get a Payin
/v1/payments/:idcurl https://api.zyndpay.io/v1/payments/TXN_ID \
-H "X-Api-Key: YOUR_API_KEY"List Payins
/v1/payments| Parameter | Type | Required | Description |
|---|---|---|---|
page | number | optional | Page number. Default: 1. |
limit | number | optional | Results per page. Default: 20. Max: 100. |
status | string | optional | Filter by status: <ic>AWAITING_PAYMENT</ic> (default state for newly-created MoMo or CARD payins — most common state on a fresh list), <ic>PENDING</ic>, <ic>CONFIRMING</ic>, <ic>CONFIRMED</ic>, <ic>EXPIRED</ic>, <ic>UNDERPAID</ic>, <ic>OVERPAID</ic>, or <ic>FAILED</ic>. |
currency | string | optional | Filter by currency (e.g. USDT_TRC20). |
curl https://api.zyndpay.io/v1/payments \
-H "X-Api-Key: YOUR_API_KEY"payins = client.payins.list(limit=20, status="CONFIRMED")
for p in payins["items"]:
print(p["transactionId"], p["status"]){
"success": true,
"data": {
"items": [ ... ],
"meta": {
"page": 1,
"limit": 20,
"total": 134,
"totalPages": 7,
"hasNext": true,
"hasPrev": false
}
}
}Fee Schedule
Fees are rail-aware and account-specific. Public documentation explains where fees appear; your authenticated dashboard, checkout, API response and merchant agreement show the current effective rate for your account.
- USDT (TRC20): current applicable rate shown in your account and API responses.
- Mobile Money (XOF): current applicable rate depends on operator, country, provider costs and account terms.
- Card (XOF — Visa / Mastercard): current applicable rate depends on card rail, provider costs and account terms.
Fees are denominated in the relevant transaction currency. The exact `zyndpayFee` value applied to a transaction is returned by the API and webhook payloads where applicable.
Create a Payin — SDK
import zyndpay
client = zyndpay.ZyndPay(api_key="YOUR_API_KEY")
payin = client.payins.create(
amount="25.00",
external_ref="order_123",
sandbox=True, # remove in production
)
print(payin["transactionId"])
print(payin["paymentUrl"])<?php
require 'vendor/autoload.php';
use ZyndPay\ZyndPay;
$client = new ZyndPay('YOUR_API_KEY');
$payin = $client->payins->create([
'amount' => '25.00',
'externalRef' => 'order_123',
], sandbox: true); // remove sandbox in production
echo $payin['transactionId'];
echo $payin['paymentUrl'];Create a Paylink
/v1/paylinksCreates a shareable payment link with one or more products. Share the returned <ic>paymentUrl</ic> with customers. Supports fixed-price, variable-price, and recurring billing types.
| Parameter | Type | Required | Description |
|---|---|---|---|
name | string | optional | Internal name for the paylink shown in the merchant dashboard. Optional, never shown to the customer. |
type | string | required | Payment link type: FIXED (default), VARIABLE, or RECURRING. |
products | array | required | Array of products. At least one product is required. |
products[].name | string | required | Product name displayed to the customer. |
products[].price | string | required | Price in USDT as a decimal string. |
products[].productType | string | required | PHYSICAL (default) or DIGITAL. |
products[].digitalUrl | string | optional | External URL for digital product delivery (Google Drive, Mega, etc.). |
collectEmail | string | optional | Customer email collection: HIDDEN (default), OPTIONAL, or REQUIRED. |
collectName | string | optional | Customer name collection: HIDDEN, OPTIONAL, or REQUIRED. |
brandColor | string | optional | Hex color for branded checkout page (e.g. #635BFF). |
successUrl | string | optional | URL to redirect the customer after a successful payment. |
recurringInterval | string | optional | Billing interval for recurring paylinks: WEEKLY, MONTHLY, or YEARLY. |
curl -X POST https://api.zyndpay.io/v1/paylinks \
-H "X-Api-Key: zyp_live_sk_..." \
-H "Content-Type: application/json" \
-d '{
"type": "FIXED",
"products": [
{
"name": "Premium Course",
"price": "49.99",
"productType": "DIGITAL",
"digitalUrl": "https://drive.google.com/file/d/abc123"
}
],
"collectEmail": "REQUIRED",
"brandColor": "#635BFF"
}'{
"success": true,
"data": {
"id": "pl_cma1xyz8f0001yx5k",
"slug": "aB3dE6fG7hI9",
"name": "Summer collection 2026",
"type": "FIXED",
"status": "ACTIVE",
"currency": "USDT_TRC20",
"paymentUrl": "https://dashboard.zyndpay.io/pay/link/aB3dE6fG7hI9",
"products": [
{
"id": "prod_abc123",
"name": "Premium Course",
"description": "Lifetime access. PDF + video bundle.",
"price": "49.990000000000000000",
"productType": "DIGITAL",
"digitalUrl": "https://drive.google.com/file/d/abc123",
"imageKey": null,
"imageUrl": null,
"stockEnabled": false,
"stockQty": 0,
"lowStockThreshold": 0,
"sortOrder": 0
}
],
"promoCodes": [],
"useCount": 0,
"maxUses": null,
"ordersCount": 0,
"expiresAt": null,
"autoDisableOnDepletion": false,
"collectEmail": "REQUIRED",
"collectName": "OPTIONAL",
"collectPhone": "OPTIONAL",
"collectAddress": "HIDDEN",
"recurringInterval": null,
"recurringIntervalCount": null,
"brandColor": null,
"logoUrl": null,
"coverImageKey": null,
"coverImageUrl": null,
"postPaymentAction": null,
"cancelUrl": null,
"createdAt": "2026-03-08T10:00:00.000Z",
"updatedAt": "2026-03-08T10:00:00.000Z"
}
}Get a Paylink
/v1/paylinks/:id{
"success": true,
"data": {
"id": "pl_cma1xyz8f0001yx5k",
"slug": "aB3dE6fG7hI9",
"name": "Summer collection 2026",
"type": "FIXED",
"status": "ACTIVE",
"currency": "USDT_TRC20",
"paymentUrl": "https://dashboard.zyndpay.io/pay/link/aB3dE6fG7hI9",
"products": [
{
"id": "prod_abc123",
"name": "Premium Course",
"description": "Lifetime access. PDF + video bundle.",
"price": "49.990000000000000000",
"productType": "DIGITAL",
"digitalUrl": "https://drive.google.com/file/d/abc123",
"imageKey": null,
"imageUrl": null,
"stockEnabled": false,
"stockQty": 0,
"lowStockThreshold": 0,
"sortOrder": 0
}
],
"promoCodes": [],
"useCount": 0,
"maxUses": null,
"ordersCount": 0,
"expiresAt": null,
"autoDisableOnDepletion": false,
"collectEmail": "REQUIRED",
"collectName": "OPTIONAL",
"collectPhone": "OPTIONAL",
"collectAddress": "HIDDEN",
"recurringInterval": null,
"recurringIntervalCount": null,
"brandColor": null,
"logoUrl": null,
"coverImageKey": null,
"coverImageUrl": null,
"postPaymentAction": null,
"cancelUrl": null,
"createdAt": "2026-03-08T10:00:00.000Z",
"updatedAt": "2026-03-08T10:00:00.000Z"
}
}List Paylinks
/v1/paylinks| Parameter | Type | Required | Description |
|---|---|---|---|
page | number | optional | Page number. Default: 1. |
limit | number | optional | Results per page. Default: 20. Max: 100. |
{
"success": true,
"data": {
"items": [ ... ],
"meta": {
"page": 1,
"limit": 20,
"total": 134,
"totalPages": 7,
"hasNext": true,
"hasPrev": false
}
}
}Checkout
/v1/pay/link/:slug/checkoutPublic endpoint (no auth required). Creates an order and payin from a payment link. The customer receives a TRON deposit address to send USDT to.
| Parameter | Type | Required | Description |
|---|---|---|---|
items | array | required | Array of product IDs and quantities to purchase. |
customerEmail | string | optional | Customer email address (required for RECURRING and digital products). |
customerName | string | optional | Customer name (optional). |
curl -X POST https://api.zyndpay.io/v1/pay/link/aB3dE6fG7hI9/checkout \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "productId": "prod_abc123", "quantity": 1 }
],
"customerEmail": "[email protected]",
"customerName": "John Doe"
}'{
"success": true,
"data": {
"orderId": "ord_xyz789",
"transactionId": "cma2abc9g0002yz6l0def",
"address": "TRXabc123def456ghi789jkl",
"amount": "49.99",
"expiresAt": "2026-03-08T11:00:00.000Z"
}
}Currency and payment methods are independent
A paylink's pricing currency (USDT or XOF) is purely a display choice. The customer-facing checkout converts the price to whichever rail the customer picks at the live exchange rate. You receive the funds in the rail's native currency — USDT for USDT_TRC20, XOF for CARD and MOBILE_MONEY — with no FX risk to ZyndPay.
Create a Paylink — SDK
paylink = client.paylinks.create(
products=[{"name": "Premium Plan", "price": "49.99", "productType": "DIGITAL"}],
type="FIXED",
collect_email="REQUIRED",
sandbox=True, # remove in production
)
print(paylink["id"])
print(paylink["slug"])$paylink = $client->paylinks->create([
'products' => [[
'name' => 'Premium Plan',
'price' => '49.99',
'productType' => 'DIGITAL',
]],
'type' => 'FIXED',
'collectEmail' => 'REQUIRED',
], sandbox: true); // remove in production
echo $paylink['id'];
echo $paylink['slug'];Simulate a Paylink Quote
/v1/paylinks/simulatecurl -X POST "https://api.zyndpay.io/v1/paylinks/simulate?sandbox=true" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"amount": "25.00", "methods": ["USDT_TRC20"], "currency": "USDT_TRC20", "feeBearer": "CLIENT"}'Paylink Stats
/v1/paylinks/:id/statscurl https://api.zyndpay.io/v1/paylinks/PAYLINK_ID/stats \
-H "X-Api-Key: YOUR_API_KEY"Paylink Orders
Retrieve all orders placed through a payment link. Each order tracks the customer, amount paid, payment method, and status.
/v1/paylinks/:id/orderscurl https://api.zyndpay.io/v1/paylinks/PAYLINK_ID/orders \
-H "X-Api-Key: YOUR_API_KEY"Promo Codes
Attach discount codes to a paylink that customers can redeem at checkout. Each code has a discount (percentage or fixed amount), an optional usage cap, and an optional expiry timestamp. Codes are scoped to a single paylink — the same string on a different paylink is a different code.
/v1/paylinks/:id/promo-codes| Parameter | Type | Required | Description |
|---|---|---|---|
code | string | required | The customer-facing code, normalized to uppercase by the API (e.g. `"tabaski"` → `"TABASKI"`). |
discountType | string | required | `PERCENT` or `FIXED`. `PERCENT` applies `discountValue`% off the subtotal; `FIXED` subtracts a flat amount in the paylink's currency. |
discountValue | string | required | For `PERCENT`, the percentage as a decimal string (e.g. `"15"` for 15%). For `FIXED`, the amount in the paylink's currency. |
maxUses | integer | optional | Maximum total redemptions across all customers. Omit for unlimited uses. |
expiresAt | string | optional | ISO 8601 timestamp after which the code is no longer redeemable (e.g. `"2026-12-31T23:59:59Z"`). Omit for no time limit. |
curl -X POST https://api.zyndpay.io/v1/paylinks/PAYLINK_ID/promo-codes \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"code": "TABASKI",
"discountType": "PERCENT",
"discountValue": "15",
"maxUses": 100,
"expiresAt": "2026-07-07T23:59:59.000Z"
}'/v1/paylinks/:id/promo-codes/v1/paylinks/:id/promo-codes/:codeId/v1/paylinks/:id/promo-codes/:codeIdValidate a Promo Code
Public endpoint your cart UI calls when a customer enters a code. Returns the discount preview if redeemable, or one of four error codes (`PROMO_CODE_INVALID`, `PROMO_CODE_INACTIVE`, `PROMO_CODE_EXPIRED`, `PROMO_CODE_USAGE_LIMIT_REACHED`) so you can show a tailored message instead of a generic validation error.
/v1/pay/link/:slug/apply-promo# Public endpoint — call from the cart UI on promo-input blur.
curl -X POST https://api.zyndpay.io/v1/pay/link/PAYLINK_SLUG/apply-promo \
-H "Content-Type: application/json" \
-d '{"code": "TABASKI"}'{
"success": false,
"error": {
"code": "PROMO_CODE_EXPIRED",
"message": "This promo code has expired."
},
"statusCode": 400
}Redemption error codes
Switch on `error.code` in your checkout UI to render the right message for each failure mode:
| error.code | When | Required | Description |
|---|---|---|---|
PROMO_CODE_INVALID | lookup | optional | Code doesn't exist on this paylink. |
PROMO_CODE_INACTIVE | state | optional | Merchant has toggled the code off via `PATCH .../promo-codes/:codeId` with `isActive: false`. |
PROMO_CODE_EXPIRED | time | optional | Past `expiresAt`. Refresh the cart UI — `expiresAt` is included on the list response. |
PROMO_CODE_USAGE_LIMIT_REACHED | count | optional | `useCount >= maxUses`. The code has been redeemed by enough customers. |
Paylink Templates
Templates let you save a paylink configuration and reuse it to create multiple links quickly. Templates store products, settings, and branding — but not the live order history.
/v1/paylinks/templates/v1/paylinks/templates/v1/paylinks/templates/:idcurl -X POST https://api.zyndpay.io/v1/paylinks/templates \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "My Template",
"config": {
"type": "FIXED",
"products": [{"name": "Product", "price": "10.00", "productType": "DIGITAL"}],
"collectEmail": "HIDDEN"
}
}'Cover & Product Images
Upload a cover image for the paylink checkout page, and per-product images for each item. Uploads use multipart/form-data.
/v1/paylinks/:id/cover-image/v1/paylinks/:id/cover-image/v1/paylinks/:id/products/:productId/image/v1/paylinks/:id/products/import-csvCreate a Payout
/v1/payoutUse <ic>POST /transfers</ic> for new outbound money movement. The legacy payout endpoint remains available for compatibility with existing API integrations.
| Parameter | Type | Required | Description |
|---|---|---|---|
amount | string | required | Amount to send (for example, "50.00"). The applicable fee is calculated from your account terms and returned before execution. |
destinationAddress | string | required | TRON (TRC20) wallet address of the recipient. Must match the format T followed by 33 base58 characters. |
currency | string | optional | Currency to send. Default: USDT_TRC20. |
chain | string | optional | Blockchain network. Default: TRON. |
externalRef | string | optional | Your internal reference ID for this payout (e.g. vendor invoice number). |
metadata | object | optional | Arbitrary key-value pairs stored with the payout. |
curl -X POST https://api.zyndpay.io/v1/payout \
-H "X-Api-Key: zyp_live_sk_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
-d '{
"amount": "50.00",
"destinationAddress": "TXYZabc123def456ghi789jkl012mno345",
"externalRef": "payout_vendor_456"
}'{
"success": true,
"data": {
"transactionId": "cma2abc9g0002yz6l0def5678",
"status": "PROCESSING",
"processingFee": "1.50",
"requiresManualApproval": false,
"currentPayinFee": "1%",
"currentTier": "flat"
}
}Estimate Payout
Preview the processing fee and total cost before creating a payout. Returns fee, totalDebited, availableBalance, and sufficient.
/v1/payout/estimate| Parameter | Type | Required | Description |
|---|---|---|---|
amount | string | required | Amount in USDT to estimate for. |
currency | string | optional | Currency (default: USDT_TRC20). |
chain | string | optional | Blockchain (default: TRON). |
destinationAddress | string | required | Destination TRON address. Required — the estimate validates the address and runs an AML pre-check. |
curl -X POST https://api.zyndpay.io/v1/payout/estimate \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"amount": "100.00", "destinationAddress": "TRON_ADDRESS"}'const estimate = await zyndpay.payouts.estimate({
amount: '100.00',
currency: 'USDT_TRC20',
chain: 'TRON',
destinationAddress: 'TXyz1234567890abcdef1234567890abcde',
});
console.log(`Fee: ${estimate.fee} USDT`);
console.log(`Total deducted: ${estimate.totalDebited} USDT`);
console.log(`Sufficient balance: ${estimate.sufficient}`);estimate = client.payouts.estimate(
amount="100.00",
destination_address="TRON_ADDRESS",
)
print(f"Fee: {estimate['fee']} USDT")
print(f"Total debited: {estimate['totalDebited']} USDT")$estimate = $client->payouts->estimate([
'amount' => '100.00',
'destinationAddress' => 'TRON_ADDRESS',
]);
echo "Fee: " . $estimate['fee'] . " USDT\n";
echo "Total: " . $estimate['totalDebited'] . " USDT\n";Create a Payout — SDK
payout = client.payouts.create(
amount="100.00",
destination_address="TRON_ADDRESS",
idempotency_key="payout_vendor_apr_001",
)
print(payout["id"], payout["status"])$payout = $client->payouts->create([
'amount' => '100.00',
'destinationAddress' => 'TRON_ADDRESS',
], idempotencyKey: 'payout_vendor_apr_001');
echo $payout['id'];Get a Payout
/v1/payout/:id{
"success": true,
"data": {
"transactionId": "cma2abc9g0002yz6l0def5678",
"status": "PROCESSING",
"processingFee": "1.50",
"requiresManualApproval": false,
"currentPayinFee": "1%",
"currentTier": "flat"
}
}PENDINGAwaiting manual approval (amount > $50K)PROCESSINGApproved, preparing broadcastBROADCASTSubmitted to TRON networkCONFIRMEDConfirmed on-chainFAILEDBroadcast failed — balance refundedCANCELLEDCancelled before broadcastList Payouts
/v1/payout{
"success": true,
"data": {
"items": [ ... ],
"meta": {
"page": 1,
"limit": 20,
"total": 42,
"totalPages": 3,
"hasNext": true,
"hasPrev": false
}
}
}Request a Withdrawal
/v1/withdrawalsTransfers let merchants move settled balances out of ZyndPay to a saved destination where supported. The applicable fee is calculated from your account terms and shown before execution.
| Parameter | Type | Required | Description |
|---|---|---|---|
amount | string | required | Amount to withdraw (for example, "500.00"). The applicable fee is calculated from your account terms and shown before execution. |
whitelistAddressId | string | optional | ID of the saved withdrawal address to use. Defaults to your primary address if omitted. |
curl -X POST https://api.zyndpay.io/v1/withdrawals \
-H "X-Api-Key: zyp_live_sk_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
-d '{
"amount": "500.00",
"whitelistAddressId": "d2f64e1f-3c3f-4c8b-bc21-1037682d691c"
}'{
"success": true,
"data": {
"id": "wdr_cma1xyz8f0001yx5k",
"amount": "500.00",
"fee": "5.00",
"netAmount": "495.00",
"destinationAddress": "TRXabc123def456ghi789jkl",
"status": "PENDING_REVIEW",
"createdAt": "2026-03-06T11:00:00.000Z"
}
}Get a Withdrawal — List
/v1/withdrawalscurl https://api.zyndpay.io/v1/withdrawals \
-H "X-Api-Key: YOUR_API_KEY"withdrawal = client.withdrawals.create(
amount="50.00",
idempotency_key="withdrawal_001",
)
print(withdrawal["id"], withdrawal["status"])Get a Withdrawal
/v1/withdrawals/:idcurl https://api.zyndpay.io/v1/withdrawals/WD_ID \
-H "X-Api-Key: YOUR_API_KEY"{
"success": true,
"data": {
"id": "wdr_cma1xyz8f0001yx5k",
"amount": "500.00",
"fee": "5.00",
"netAmount": "495.00",
"destinationAddress": "TRXabc123def456ghi789jkl",
"status": "PENDING_REVIEW",
"createdAt": "2026-03-06T11:00:00.000Z"
}
}PENDING_REVIEWAwaiting compliance review (PENDING_REVIEW)APPROVEDApproved, queued for processingBROADCASTSubmitted to TRON networkCONFIRMEDConfirmed on-chainREJECTEDRejected by admin reviewFAILEDBroadcast failed — balance refundedCANCELLEDCancelled before broadcastCancel a Withdrawal
/v1/withdrawals/:idCancels a withdrawal request that is still in <ic>PENDING_REVIEW</ic> status (before it is approved and broadcast to the TRON network). The amount is immediately returned to your balance.
curl -X DELETE https://api.zyndpay.io/v1/withdrawals/WD_ID \
-H "X-Api-Key: YOUR_API_KEY"Bulk Payments
Bulk payments let you send to hundreds of recipients in a single batch. Build a batch in DRAFT, validate it (fee preview + balance check), then execute — each item is processed independently so a single failure does not block the rest.
Create Batch
Create an empty batch. Optionally set a label for internal reference.
/v1/bulk-payments| Parameter | Type | Required | Description |
|---|---|---|---|
label | string | optional | Internal label for this batch (optional). |
curl -X POST https://api.zyndpay.io/v1/bulk-payments \
-H "X-Api-Key: zyp_live_sk_..." \
-H "Content-Type: application/json" \
-d '{ "label": "Vendor payouts April 2026" }'Add Items
Add recipients to a DRAFT batch. Each item needs a wallet address, amount, and optional metadata. You can also import a CSV or XLSX file.
/v1/bulk-payments/:id/items| Parameter | Type | Required | Description |
|---|---|---|---|
items | array | required | Array of payment items. Each item: walletAddress, amount, recipientName (optional), reference (optional). |
curl -X POST https://api.zyndpay.io/v1/bulk-payments/batch_abc/items \
-H "X-Api-Key: zyp_live_sk_..." \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "walletAddress": "TXyz1...", "amount": "100.00", "recipientName": "Vendor A", "reference": "inv_001" },
{ "walletAddress": "TXyz2...", "amount": "250.00", "recipientName": "Vendor B", "reference": "inv_002" }
]
}'Validate & Execute
Validation checks balance sufficiency and previews total fees without executing. The response carries <ic>balanceCheck</ic>: on live batches it returns <ic>{ sufficient, required, available }</ic> (and 400 INSUFFICIENT_BALANCE when the ledger is short); on sandbox batches it returns <ic>{ sandbox: true, skipped: true, required }</ic> — sandbox batches don't touch the ledger so the balance check is intentionally skipped. Execute re-runs the live balance check inside a serializable transaction (in case the balance moved between validate and execute) before debiting and queuing items.
curl -X POST https://api.zyndpay.io/v1/bulk-payments/batch_abc/validate \
-H "X-Api-Key: zyp_live_sk_..."curl -X POST https://api.zyndpay.io/v1/bulk-payments/batch_abc/execute \
-H "X-Api-Key: zyp_live_sk_..."// Full bulk payment workflow
const batch = await zyndpay.bulkPayments.create({ label: 'Vendor payouts April 2026' });
await zyndpay.bulkPayments.addItems(batch.id, {
items: [
{ walletAddress: 'TXyz1...', amount: '100.00', recipientName: 'Vendor A', reference: 'inv_001' },
{ walletAddress: 'TXyz2...', amount: '250.00', recipientName: 'Vendor B', reference: 'inv_002' },
],
});
const validation = await zyndpay.bulkPayments.validate(batch.id);
console.log(`Total fee: ${validation.totalFee} USDT`);
await zyndpay.bulkPayments.execute(batch.id);
// Poll for completion
const status = await zyndpay.bulkPayments.get(batch.id);
console.log(status.status); // PROCESSING | COMPLETED | PARTIALLY_COMPLETED# Create batch
batch = client.bulk_payments.create(label="April payouts")
# Add recipients
client.bulk_payments.add_items(batch["id"], [
{"walletAddress": "TRON_ADDRESS_1", "amount": "50.00", "recipientName": "Vendor A"},
{"walletAddress": "TRON_ADDRESS_2", "amount": "75.00", "recipientName": "Vendor B"},
])
# Validate then execute
client.bulk_payments.validate(batch["id"])
client.bulk_payments.execute(batch["id"])// Create batch
$batch = $client->bulkPayments->create(['label' => 'April payouts']);
// Add recipients
$client->bulkPayments->addItems($batch['id'], [
['walletAddress' => 'TRON_ADDRESS_1', 'amount' => '50.00', 'recipientName' => 'Vendor A'],
['walletAddress' => 'TRON_ADDRESS_2', 'amount' => '75.00', 'recipientName' => 'Vendor B'],
]);
// Validate and execute
$client->bulkPayments->validate($batch['id']);
$client->bulkPayments->execute($batch['id']);Retry Failed Items
After a batch reaches PARTIALLY_COMPLETED status, call retry to re-queue only the failed items. Items that already succeeded are not re-processed.
/v1/bulk-payments/:id/retryCancel a Batch
Cancel a batch that is still in DRAFT or VALIDATED status. Funds are not reserved until execute() is called, so cancellation is immediate with no balance impact.
/v1/bulk-payments/:id/cancelMonitor
Poll batch status and per-item results. Export a CSV report when complete.
/v1/bulk-payments/:id/v1/bulk-payments/:id/exportcurl https://api.zyndpay.io/v1/bulk-payments/batch_abc \
-H "X-Api-Key: zyp_live_sk_..."Wallets & Conversions
ZyndPay maintains one USDT wallet (TRON rail, on-chain) plus one XOF wallet per fiat rail (currently <ic>MOMO</ic> and <ic>CARD</ic>) — three wallets total. Each entry exposes its own <ic>currency</ic>, <ic>rail</ic>, balance, settlement flow, and withdrawal path. Always identify a wallet by the <ic>(currency, rail)</ic> pair: filtering only on <ic>currency === 'XOF'</ic> matches the first XOF entry returned and silently misses the other rail. Conversions let you move value between them at the live rate.
List Wallets
Returns one wallet per (currency, rail) pair. Use the wallet id when calling conversions or withdrawals.
/v1/merchants/walletscurl https://api.zyndpay.io/v1/merchants/wallets \
-H "X-Api-Key: YOUR_API_KEY"const wallets = await zyndpay.wallets.list();
// Always identify a wallet by the (currency, rail) pair — XOF is split per rail.
const usdtWallet = wallets.find(w => w.currency === 'USDT_TRC20' && w.rail === 'TRC20');
const xofMomoWallet = wallets.find(w => w.currency === 'XOF' && w.rail === 'MOMO');
const xofCardWallet = wallets.find(w => w.currency === 'XOF' && w.rail === 'CARD');
console.log(usdtWallet.id, usdtWallet.balance);
console.log(xofMomoWallet.balance, xofCardWallet.balance);wallets = client.wallets.list()
for w in wallets:
print(f"{w['currency']} ({w['rail']}): {w['balance']}")$wallets = $client->wallets->list();
foreach ($wallets as $wallet) {
echo $wallet['currency'] . ': ' . $wallet['balance'] . "\n";
}Address Whitelist
The address whitelist is the set of TRON addresses your account is authorized to withdraw USDT to. Every withdrawal destination — whether initiated from the dashboard or via API — must be on your whitelist first. Addresses can be added one at a time or in bulk batches of up to 500.
Add an Address
Adds a single TRON address to the whitelist. If the address already exists, the call succeeds and optionally merges new usage contexts.
/v1/wallets/whitelistcurl -X POST https://api.zyndpay.io/v1/wallets/whitelist \
-H "Authorization: Bearer zyp_live_sk_..." \
-H "Content-Type: application/json" \
-d '{
"currency": "USDT_TRC20",
"chain": "TRON",
"address": "TRX_ADDRESS_HERE",
"label": "My payout wallet",
"usageContexts": ["WITHDRAWAL", "PAYOUT"]
}'const entry = await zyndpay.whitelist.add({
currency: 'USDT_TRC20',
chain: 'TRON',
address: 'TRX_ADDRESS_HERE',
label: 'My payout wallet',
usageContexts: ['WITHDRAWAL', 'PAYOUT'], // optional, defaults to ['WITHDRAWAL']
});
// entry.availableAt — earliest time this address can receive a withdrawal
console.log('Available for withdrawal at:', entry.availableAt);Bulk Add Addresses
Add up to 500 addresses in a single call. Pass an optional <ic>usageContexts</ic> array (defaults to <ic>['WITHDRAWAL']</ic>) — applied to every address in the batch. To make addresses eligible for both withdrawals and payouts at create time, send <ic>['WITHDRAWAL', 'PAYOUT']</ic>. The response breaks down results into <ic>added</ic> (newly inserted), <ic>alreadyExists</ic> (skipped — already on your list), and <ic>invalid</ic> (failed TRON address validation). The 24-hour cooldown applies individually to each newly added address.
/v1/wallets/whitelist/bulkcurl -X POST https://api.zyndpay.io/v1/wallets/whitelist/bulk \
-H "Authorization: Bearer zyp_live_sk_..." \
-H "Content-Type: application/json" \
-d '{
"addresses": [
{ "address": "TRX_ADDRESS_1", "label": "Customer wallet A" },
{ "address": "TRX_ADDRESS_2", "label": "Customer wallet B" }
],
"usageContexts": ["WITHDRAWAL", "PAYOUT"]
}'// Add up to 500 addresses per call
const result = await zyndpay.whitelist.bulkAdd({
addresses: [
{ address: 'TRX_ADDRESS_1', label: 'Customer wallet A' },
{ address: 'TRX_ADDRESS_2', label: 'Customer wallet B' },
],
usageContexts: ['WITHDRAWAL', 'PAYOUT'], // applied to every address in the batch
});
console.log(`Added: ${result.added.length}`);
console.log(`Already existed: ${result.alreadyExists.length}`);
console.log(`Invalid: ${result.invalid.length}`);
// Each added entry includes availableAt (24h from now)Update Usage Contexts
Replace the <ic>usageContexts</ic> array on an existing whitelist entry — for example to widen a WITHDRAWAL-only address to also accept PAYOUT, without re-adding it (which would restart the 24-hour cooldown). The body must contain at least one valid context.
/v1/wallets/whitelist/:id/contextscurl -X PATCH https://api.zyndpay.io/v1/wallets/whitelist/ENTRY_ID/contexts \
-H "Authorization: Bearer zyp_live_sk_..." \
-H "Content-Type: application/json" \
-d '{
"usageContexts": ["WITHDRAWAL", "PAYOUT"]
}'List Whitelist
Returns all addresses currently on your whitelist, ordered by most recently added. Optionally filter by usage context.
/v1/wallets/whitelistcurl https://api.zyndpay.io/v1/wallets/whitelist \
-H "Authorization: Bearer zyp_live_sk_..."Saved addresses cannot be deleted
Saved crypto recipients are permanent. Add new recipients when needed; existing recipients stay visible for audit history and transfer security.
Fiat Destinations
A fiat destination is a mobile-money number or bank account that can receive XOF withdrawals. At least one must be registered before you can withdraw XOF funds.
/v1/merchants/fiat-destinations/v1/merchants/fiat-destinations/v1/merchants/fiat-destinations/:id/v1/merchants/fiat-destinations/:idFor MOMO: kind, label, momoOperator (ORANGE_BF, MOOV_BF), momoPhone (E.164 format).
For BANK: kind, label, bankName, bankAccountName, bankIban or bankAccountNumber, bankCode.
curl -X POST https://api.zyndpay.io/v1/merchants/fiat-destinations \
-H "X-Api-Key: zyp_live_sk_..." \
-H "Content-Type: application/json" \
-d '{
"kind": "MOMO",
"label": "Orange Money BF",
"momoOperator": "ORANGE_BF",
"momoPhone": "+22670123456",
"isPrimary": true
}'// Register a mobile money destination
const dest = await zyndpay.fiatDestinations.create({
kind: 'MOMO',
label: 'Orange Money BF',
momoOperator: 'ORANGE_BF',
momoPhone: '+22670123456',
isPrimary: true,
});
// Note: 24-hour cooldown before first use (BCEAO compliance)Convert Between Wallets — Preview
Preview the destination amount before committing to a wallet conversion. The source and destination currencies come from the selected wallets; the contract is not tied to one currency pair.
/v1/conversions/wallet/previewcurl "https://api.zyndpay.io/v1/conversions/wallet/preview?fromCurrency=XOF&toCurrency=USDT&amount=50000" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json"Convert Between Wallets — List
/v1/conversionscurl https://api.zyndpay.io/v1/conversions \
-H "X-Api-Key: YOUR_API_KEY"Convert Between Wallets
Convert funds from one wallet to another using server-side pricing. Typical use: convert XOF received from card or mobile-money payins into USDT in your USDT wallet.
/v1/conversions/wallet| Parameter | Type | Required | Description |
|---|---|---|---|
fromWalletId | string | required | Source wallet ID (from list wallets). |
toWalletId | string | required | Destination wallet ID. |
fromAmount | string | required | Amount to convert from the source wallet. |
// Convert XOF earnings (MOMO rail) to USDT
const wallets = await zyndpay.wallets.list();
const xof = wallets.find(w => w.currency === 'XOF' && w.rail === 'MOMO')!;
const usdt = wallets.find(w => w.currency === 'USDT_TRC20' && w.rail === 'TRC20')!;
const conversion = await zyndpay.conversions.convertBetweenWallets({
fromWalletId: xof.id,
toWalletId: usdt.id,
fromAmount: '50000',
});
console.log(`Converted: ${conversion.fromAmount} ${conversion.fromCurrency}`);
console.log(`Received: ${conversion.toAmountNet} ${conversion.toCurrency}`);from zyndpay import ZyndPay
client = ZyndPay(api_key="YOUR_API_KEY")
wallets = client.wallets.list()
usdt_wallet = next(w for w in wallets if w["currency"] == "USDT_TRC20")
xof_wallet = next(w for w in wallets if w["currency"] == "XOF")
conversion = client.conversions.convert_between_wallets(
from_wallet_id=usdt_wallet["id"],
to_wallet_id=xof_wallet["id"],
from_amount="100.00",
)
print(conversion["id"], conversion["status"])Get a Transaction
Retrieve a single transaction by its ID. The ID can be a payin transactionId, a payout ID, or a withdrawal ID — all transaction types share the same endpoint.
/v1/transactions/:idcurl https://api.zyndpay.io/v1/transactions/TX_ID \
-H "X-Api-Key: YOUR_API_KEY"List Transactions
Unified history across payins, payouts, and withdrawals. Filter by type, status, currency, or date range.
/v1/transactions| Parameter | Type | Required | Description |
|---|---|---|---|
type | string | optional | Filter by type: PAYIN, PAYOUT, WITHDRAWAL. |
status | string | optional | Filter by status. |
currency | string | optional | Filter by currency (e.g. USDT_TRC20). |
from | string | optional | Start date (ISO 8601). |
to | string | optional | End date (ISO 8601). |
page | number | optional | Page number. Default: 1. |
limit | number | optional | Results per page. Default: 20. Max: 100. |
curl "https://api.zyndpay.io/v1/transactions?limit=20&type=PAYIN" \
-H "X-Api-Key: YOUR_API_KEY"const txs = await zyndpay.transactions.list({
type: 'PAYIN',
status: 'CONFIRMED',
from: '2026-01-01',
to: '2026-04-30',
});
console.log(`${txs.total} transactions found`);txns = client.transactions.list(limit=20, type="PAYIN")
for t in txns["items"]:
print(t["id"], t["type"], t["status"], t["amountRequested"])$result = $client->transactions->list([
'limit' => 20,
'type' => 'PAYIN',
]);
foreach ($result['items'] as $tx) {
echo $tx['id'] . ' ' . $tx['status'] . "\n";
}Export Transactions
Generate a CSV or PDF of your transaction history. CSV exports run as a background job; PDF is returned synchronously.
/v1/transactions/export/v1/transactions/export/pdfcurl "https://api.zyndpay.io/v1/transactions/export?from=2026-01-01&to=2026-12-31" \
-H "X-Api-Key: YOUR_API_KEY" -o transactions.csv// Download CSV export
const csv = await zyndpay.transactions.export({
from: '2026-01-01',
to: '2026-04-30',
});
// csv is a string — write to file or send to browser
// Download PDF report
const pdf = await zyndpay.transactions.exportPdf();Overview
ZyndPay sends HTTP POST requests to your registered endpoint URLs when payment events occur. Register endpoints from the <strong>Webhooks</strong> page in your merchant dashboard.
{
"event": "payin.confirmed",
"data": {
"transactionId": "cma1xyz8f0001yx5k9abc1234",
"status": "CONFIRMED",
"currency": "XOF",
"chain": "TRON",
"externalRef": "order_123",
"amount": "9300.00",
"amountRequested": "9300.00",
"txHash": "abc123def456...",
"confirmedAt": "2026-03-06T11:01:02.000Z",
"pricingCurrency": "USDT_TRC20",
"pricingAmount": "15.00",
"fxRate": "620.00000000"
},
"createdAt": "2026-03-06T11:01:03.000Z"
}Signature Verification
Every webhook delivery includes an <ic>Zyndpay-Signature</ic> header. Always verify it before processing the event.
t=1680000000,v1=5257a869e7ecebeda32af...The signature is <ic>HMAC-SHA256(timestamp + "." + raw_body, webhook_secret)</ic>. Always use the <strong>raw request body</strong> — parsing JSON first will cause verification failures.
const crypto = require('crypto');
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['zyndpay-signature'];
const [tPart, v1Part] = sig.split(',');
const timestamp = tPart.split('=')[1];
const received = v1Part.split('=')[1];
// Reject events older than 5 minutes
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp) / 1000);
if (age > 300) return res.status(400).send('Webhook too old');
const expected = crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(`${timestamp}.${req.body}`)
.digest('hex');
// Use timing-safe comparison to prevent timing attacks
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(req.body);
// Handle event...
res.json({ received: true });
});Webhook Endpoints
Manage your webhook endpoints programmatically instead of (or in addition to) the dashboard UI.
/v1/webhooks/endpoints| Parameter | Type | Required | Description |
|---|---|---|---|
url | string | required | HTTPS URL that ZyndPay will POST events to. |
events | array | required | Array of event names to subscribe to (e.g. ["payin.confirmed", "payout.failed"]). |
retryConfig | object | optional | Optional retry tuning. Response exposes <ic>maxRetries</ic> (default 3), <ic>retryBackoff</ic> (enum: <ic>EXPONENTIAL</ic>), and <ic>retryIntervalSeconds</ic> (default 60). Request body accepts the same fields at the top level. |
/v1/webhooks/endpoints/v1/webhooks/endpoints/:id/v1/webhooks/endpoints/:id/v1/webhooks/endpoints/:id/rotate-secret/v1/webhooks/endpoints/:id/reactivateconst endpoint = await zyndpay.webhooks.createEndpoint({
url: 'https://example.com/webhooks/zyndpay',
events: ['payin.confirmed', 'payout.failed', 'withdrawal.confirmed'],
});
// Store endpoint.secret (whs_* prefix) — shown only once!
process.env.ZYNDPAY_WEBHOOK_SECRET = endpoint.secret;Events
| Event | Type | Required | Description |
|---|---|---|---|
payin.created | event | required | Fired when a new payin is created and a deposit address is generated. |
payin.confirming | event | required | Fired when funds are received and awaiting 5 block confirmations. |
payin.confirmed | event | required | Fired when a pay-in is fully settled — for USDT after 5 on-chain confirmations, for card after the issuing-bank capture, for mobile money after the operator's confirmation. The merchant balance is credited. |
payin.expired | event | required | Fired when a payin address expires without receiving the expected amount. |
payin.failed | event | required | Fired when a pay-in fails on any rail — card decline, mobile money provider failure, or USDT received below the dust threshold. The data envelope includes a `reason` string with the operator-readable failure cause. |
payin.underpaid | event | required | Fired when a payin receives less than the requested amount. |
payin.overpaid | event | required | Fired when a payin receives more than the requested amount. |
deposit.confirmed | event | required | Fired when a wallet deposit reaches 20 on-chain confirmations. Merchant balance is credited. |
deposit.failed | event | required | Fired when a wallet deposit fails to confirm. |
deposit.overpaid | event | required | Fired when a wallet deposit receives more than the expected amount. |
deposit.underpaid | event | required | Fired when a wallet deposit receives less than the expected amount. |
payout.requested | event | required | Fired when a new payout is requested and queued for compliance review. Emitted alongside the legacy `withdrawal.requested` event for the same lifecycle. |
payout.approved | event | required | Fired when a payout passes review and is approved for on-chain broadcast. Emitted alongside the legacy `withdrawal.approved` event for the same lifecycle. |
payout.broadcast | event | required | Fired when a payout transaction is signed and broadcast to the TRON network. |
payout.confirmed | event | required | Fired when a payout reaches 20 on-chain confirmations. |
payout.failed | event | required | Fired when a payout broadcast fails. Amount is refunded to merchant balance. |
withdrawal.requested | event | required | Fired when a new withdrawal is requested and queued for compliance review. |
withdrawal.broadcast | event | required | Fired when a withdrawal is signed and broadcast to TRON. |
withdrawal.approved | event | required | Fired when a withdrawal passes review and is approved for on-chain broadcast. |
withdrawal.confirmed | event | required | Fired when a withdrawal is confirmed on-chain. |
withdrawal.failed | event | required | Fired when a withdrawal broadcast fails. Amount is refunded to balance. |
conversion.confirmed | event | required | Fired when a wallet-to-wallet conversion completes successfully. |
conversion.failed | event | required | Fired when a conversion fails. Source funds are returned to the originating wallet. |
subscription.created | event | required | Fired when a recurring subscription is first created on a paylink checkout. The data envelope includes subscriptionId, paylinkId, customer info, and the first billing period. |
subscription.renewed | event | required | Fired when a subscription successfully charges for the next billing cycle. |
subscription.renewal_initiated | event | required | Fired when a subscription begins its renewal cycle (before charge succeeds). |
subscription.failed | event | required | Fired when a subscription renewal charge fails. |
subscription.cancelled | event | required | Fired when a subscription is cancelled (by merchant or customer). |
subscription.paused | event | required | Fired when a subscription is paused. |
subscription.resumed | event | required | Fired when a paused subscription is resumed. |
subscription.updated | event | required | Fired when subscription details (amount, interval) are updated. |
refund.created | event | required | Fired when a refund record is created (pending approval). |
refund.approved | event | required | Fired when a refund is approved by ops. |
refund.rejected | event | required | Fired when a refund is rejected. |
refund.completed | event | required | Fired when a refund is fully disbursed back to the customer. |
refund.failed | event | required | Fired when a refund disbursement fails. |
dispute.opened | event | required | Fired when a dispute is opened against a transaction. |
dispute.resolved | event | required | Fired when a dispute is resolved. |
dispute.rejected | event | required | Fired when a dispute is rejected. |
dispute.escalated | event | required | Fired when a dispute is escalated to a higher tier. |
aml.flagged | event | required | Fired when a payout is blocked by AML screening. Subscribe to react to compliance holds. |
splitpayment.created | event | required | Marketplace / Connect only. Fired on the platform merchant's endpoint after a pay-in is split across sub-merchants. Includes the allocation breakdown so the platform can reconcile against its own ledger. |
kyb.approved | event | required | docs.eventKybApprovedDesc |
kyb.rejected | event | required | docs.eventKybRejectedDesc |
kyb.thread.created | event | required | A new compliance thread was created on a KYB review. |
kyb.thread.replied | event | required | A merchant or admin replied to a KYB compliance thread (author field distinguishes). |
kyb.thread.resolved | event | required | An admin resolved a KYB compliance thread. |
kyb.thread.dismissed | event | required | An admin dismissed a KYB compliance thread. |
agreement.resign_requested | event | required | Merchant received re-sign request for MSA v3. Action required within 30 days. |
agreement.resigned | event | required | Merchant completed re-sign of MSA v3. |
Payload Schemas
Every webhook is a JSON POST with the same envelope: event, data, createdAt. The shape of data depends on the event type. Click an event below to see its exact payload.
{
"event": "payin.confirmed",
"data": {
"transactionId": "cma1xyz8f0001yx5k9abc1234",
"status": "CONFIRMED",
"currency": "XOF",
"chain": "TRON",
"externalRef": "order_123",
"amount": "9300.00",
"amountRequested": "9300.00",
"txHash": "abc123def456...",
"confirmedAt": "2026-03-06T11:01:02.000Z",
"pricingCurrency": "USDT_TRC20",
"pricingAmount": "15.00",
"fxRate": "620.00000000"
},
"createdAt": "2026-03-06T11:01:03.000Z"
}{
"event": "payout.confirmed",
"data": {
"transactionId": "tx_sample_payout123",
"status": "CONFIRMED",
"currency": "USDT_TRC20",
"chain": "TRON",
"amount": "50.00",
"fee": "1.50",
"destinationAddress": "TXyz1234567890AbCdEfGhIjKlMnOpQrSt",
"txHash": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
"externalRef": "vendor_invoice_456",
"confirmedAt": "2026-01-15T12:01:00.000Z"
},
"createdAt": "2026-01-15T12:01:05.000Z"
}{
"event": "withdrawal.confirmed",
"data": {
"transactionId": "wdr_sample_abc123",
"status": "CONFIRMED",
"currency": "USDT_TRC20",
"chain": "TRON",
"amount": "500.00",
"fee": "5.00",
"netAmount": "495.00",
"toAddress": "TXyz1234567890AbCdEfGhIjKlMnOpQrSt",
"txHash": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
"confirmedAt": "2026-01-15T12:01:00.000Z"
},
"createdAt": "2026-01-15T12:01:05.000Z"
}Test Webhook
Send a test event to one of your registered webhook endpoints. Useful for verifying your endpoint is reachable and your signature verification code works correctly.
/v1/webhooks/test| Parameter | Type | Required | Description |
|---|---|---|---|
endpointId | string | required | The ID of the webhook endpoint to send the test event to. Get this from GET /webhooks/endpoints. |
eventType | string | required | The event type to simulate (e.g. payin.confirmed, payout.confirmed). Must be a valid event name. |
curl -X POST https://api.zyndpay.io/v1/webhooks/test \
-H "X-Api-Key: zyp_live_sk_..." \
-H "Content-Type: application/json" \
-d '{
"endpointId": "your-endpoint-id",
"eventType": "payin.confirmed"
}'{
"success": true,
"data": {
"message": "Test event sent",
"deliveryId": "9270e4aa-8391-4e3f-a70b-23c431714b84"
}
}Payload Schemas
Every webhook delivery shares the same envelope: an event string, a data object with event-specific fields, and a createdAt timestamp. Examples below show exact field names and types.
payin.createdFired immediately when a new payin is created and a TRON deposit address is assigned.
{
"event": "payin.created",
"data": {
"transactionId": "cma1xyz8f0001yx5k9abc1234",
"address": "TRXabc123def456ghi789jkl",
"amount": "100.00",
"currency": "USDT_TRC20",
"chain": "TRON",
"externalRef": "order_123",
"status": "AWAITING_PAYMENT",
"expiresAt": "2026-03-06T12:00:00.000Z",
"paymentUrl": "https://checkout.zyndpay.io/cma1xyz8f0001yx5k9abc1234"
},
"createdAt": "2026-03-06T11:00:00.000Z"
}payin.confirmedFired when a payin reaches 5 on-chain confirmations. Merchant balance is credited at this point.
{
"event": "payin.confirmed",
"data": {
"transactionId": "cma1xyz8f0001yx5k9abc1234",
"address": "TRXabc123def456ghi789jkl",
"amount": "100.00",
"amountReceived": "100.00",
"currency": "USDT_TRC20",
"chain": "TRON",
"externalRef": "order_123",
"txHash": "abc123def456...",
"status": "CONFIRMED",
"confirmedAt": "2026-03-06T11:01:02.000Z"
},
"createdAt": "2026-03-06T11:01:03.000Z"
}payin.expiredFired when a payin address expires without receiving the expected USDT amount.
{
"event": "payin.expired",
"data": {
"transactionId": "cma1xyz8f0001yx5k9abc1234",
"address": "TRXabc123def456ghi789jkl",
"amount": "100.00",
"currency": "USDT_TRC20",
"chain": "TRON",
"externalRef": "order_123",
"status": "EXPIRED",
"expiredAt": "2026-03-06T12:00:00.000Z"
},
"createdAt": "2026-03-06T12:00:01.000Z"
}payin.failedFired when a pay-in fails on any rail — card decline, mobile money provider failure, or USDT received below the dust threshold. The `reason` string carries the operator-readable failure cause. No funds were captured.
{
"event": "payin.failed",
"data": {
"transactionId": "cma1xyz8f0001yx5k9abc1234",
"address": "TRXabc123def456ghi789jkl",
"amount": "100.00",
"currency": "USDT_TRC20",
"chain": "TRON",
"externalRef": "order_123",
"status": "FAILED",
"reason": "Network processing error"
},
"createdAt": "2026-03-06T11:05:00.000Z"
}payin.underpaidFired when the customer sends less USDT than the requested amount. The shortfall field shows how much is missing.
{
"event": "payin.underpaid",
"data": {
"transactionId": "cma1xyz8f0001yx5k9abc1234",
"address": "TRXabc123def456ghi789jkl",
"amount": "100.00",
"amountReceived": "90.00",
"shortfall": "10.00",
"currency": "USDT_TRC20",
"chain": "TRON",
"externalRef": "order_123",
"txHash": "abc123def456...",
"status": "UNDERPAID",
"confirmedAt": "2026-03-06T11:01:02.000Z"
},
"createdAt": "2026-03-06T11:01:03.000Z"
}payin.overpaidFired when the customer sends more USDT than the requested amount. The surplus field shows the excess.
{
"event": "payin.overpaid",
"data": {
"transactionId": "cma1xyz8f0001yx5k9abc1234",
"address": "TRXabc123def456ghi789jkl",
"amount": "100.00",
"amountReceived": "110.00",
"surplus": "10.00",
"currency": "USDT_TRC20",
"chain": "TRON",
"externalRef": "order_123",
"txHash": "abc123def456...",
"status": "OVERPAID",
"confirmedAt": "2026-03-06T11:01:02.000Z"
},
"createdAt": "2026-03-06T11:01:03.000Z"
}payout.confirmedFired when an outbound payout is confirmed on-chain. The txHash is the on-chain transaction ID.
{
"event": "payout.confirmed",
"data": {
"transactionId": "cma2abc9g0002yz6l0def5678",
"destinationAddress": "TXYZabc123def456ghi789jkl012mno345",
"amount": "50.00",
"fee": "1.50",
"currency": "USDT_TRC20",
"chain": "TRON",
"externalRef": "payout_vendor_456",
"txHash": "def456abc123...",
"status": "CONFIRMED",
"confirmedAt": "2026-03-06T11:05:00.000Z"
},
"createdAt": "2026-03-06T11:05:01.000Z"
}payout.failedFired when a payout broadcast fails. The amount is automatically refunded to your merchant balance.
{
"event": "payout.failed",
"data": {
"transactionId": "cma2abc9g0002yz6l0def5678",
"destinationAddress": "TXYZabc123def456ghi789jkl012mno345",
"amount": "50.00",
"currency": "USDT_TRC20",
"chain": "TRON",
"externalRef": "payout_vendor_456",
"status": "FAILED",
"reason": "Broadcast failed — balance refunded",
"failedAt": "2026-03-06T11:05:00.000Z"
},
"createdAt": "2026-03-06T11:05:01.000Z"
}withdrawal.approvedFired when a withdrawal request passes admin review and is queued for on-chain broadcast.
{
"event": "withdrawal.approved",
"data": {
"id": "wdr_cma1xyz8f0001yx5k",
"amount": "500.00",
"fee": "5.00",
"netAmount": "495.00",
"destinationAddress": "TRXabc123def456ghi789jkl",
"currency": "USDT_TRC20",
"chain": "TRON",
"status": "APPROVED",
"approvedAt": "2026-03-06T12:00:00.000Z"
},
"createdAt": "2026-03-06T12:00:01.000Z"
}withdrawal.confirmedFired when a withdrawal is confirmed on-chain. netAmount is what your wallet actually received after fees.
{
"event": "withdrawal.confirmed",
"data": {
"id": "wdr_cma1xyz8f0001yx5k",
"amount": "500.00",
"fee": "5.00",
"netAmount": "495.00",
"destinationAddress": "TRXabc123def456ghi789jkl",
"currency": "USDT_TRC20",
"chain": "TRON",
"txHash": "ghi789abc123...",
"status": "CONFIRMED",
"confirmedAt": "2026-03-06T12:30:00.000Z"
},
"createdAt": "2026-03-06T12:30:01.000Z"
}withdrawal.failedFired when a withdrawal broadcast fails. The amount is automatically refunded to your balance.
{
"event": "withdrawal.failed",
"data": {
"id": "wdr_cma1xyz8f0001yx5k",
"amount": "500.00",
"destinationAddress": "TRXabc123def456ghi789jkl",
"currency": "USDT_TRC20",
"chain": "TRON",
"status": "FAILED",
"reason": "Broadcast failed — balance refunded",
"failedAt": "2026-03-06T12:30:00.000Z"
},
"createdAt": "2026-03-06T12:30:01.000Z"
}Node.js SDK
The official TypeScript SDK with full type safety and built-in webhook verification.
npm install @zyndpay/sdkconst { ZyndPay } = require('@zyndpay/sdk');
const client = new ZyndPay({
apiKey: 'zyp_live_sk_...',
webhookSecret: process.env.ZYNDPAY_WEBHOOK_SECRET,
});
// Create a payment
const payment = await client.payins.create({
amount: '100.00',
externalRef: 'order_123',
});
console.log(payment.address);
// Verify a webhook (uses raw body, not parsed JSON)
const event = client.webhooks.verify(
rawBody,
req.headers['zyndpay-signature']
);TypeScript Quickstart
Accept your first USDT payment in under 5 minutes. This example uses Express — swap in your framework of choice.
import { ZyndPay } from '@zyndpay/sdk';
import express from 'express';
// 1. Initialize the SDK
const client = new ZyndPay({
apiKey: process.env.ZYNDPAY_API_KEY, // zyp_live_sk_...
webhookSecret: process.env.WEBHOOK_SECRET,
});
// 2. Create a pay-in (generates a TRON deposit address for your customer)
const payment = await client.payins.create({
amount: '100.00',
externalRef: 'order_123',
expiresInSeconds: 3600,
successUrl: 'https://yoursite.com/payment/success',
});
// Send payment.paymentUrl or payment.address to your customer
console.log('Deposit address:', payment.address);
console.log('Payment page:', payment.paymentUrl);
// 3. Handle webhook events
const app = express();
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
// Always verify using the raw body — never parsed JSON
const event = client.webhooks.verify(
req.body,
req.headers['zyndpay-signature'] as string,
);
switch (event.event) {
case 'payin.confirmed':
console.log(`Payment confirmed: ${event.data.amount} USDT — ${event.data.externalRef}`);
// Fulfill the order, unlock access, send receipt, etc.
break;
case 'payin.expired':
console.log(`Payment expired: ${event.data.externalRef}`);
break;
}
res.json({ received: true });
});
// 4. Check payment status anytime
const status = await client.payins.get(payment.transactionId);
console.log('Status:', status.status); // AWAITING_PAYMENT → CONFIRMING → CONFIRMEDNew Resources (v1.5.0+)
The following resources are available as of SDK v1.5.0: wallets, fiat destinations, conversions, bulk payments, transactions export, and balances.
// Convert XOF earnings (MOMO rail) to USDT
const wallets = await zyndpay.wallets.list();
const xof = wallets.find(w => w.currency === 'XOF' && w.rail === 'MOMO')!;
const usdt = wallets.find(w => w.currency === 'USDT_TRC20' && w.rail === 'TRC20')!;
const conversion = await zyndpay.conversions.convertBetweenWallets({
fromWalletId: xof.id,
toWalletId: usdt.id,
fromAmount: '50000',
});
console.log(`Converted: ${conversion.fromAmount} ${conversion.fromCurrency}`);
console.log(`Received: ${conversion.toAmountNet} ${conversion.toCurrency}`);// Register a mobile money destination
const dest = await zyndpay.fiatDestinations.create({
kind: 'MOMO',
label: 'Orange Money BF',
momoOperator: 'ORANGE_BF',
momoPhone: '+22670123456',
isPrimary: true,
});
// Note: 24-hour cooldown before first use (BCEAO compliance)const wallets = await zyndpay.wallets.list();
// Always identify a wallet by the (currency, rail) pair — XOF is split per rail.
const usdtWallet = wallets.find(w => w.currency === 'USDT_TRC20' && w.rail === 'TRC20');
const xofMomoWallet = wallets.find(w => w.currency === 'XOF' && w.rail === 'MOMO');
const xofCardWallet = wallets.find(w => w.currency === 'XOF' && w.rail === 'CARD');
console.log(usdtWallet.id, usdtWallet.balance);
console.log(xofMomoWallet.balance, xofCardWallet.balance);// Download CSV export
const csv = await zyndpay.transactions.export({
from: '2026-01-01',
to: '2026-04-30',
});
// csv is a string — write to file or send to browser
// Download PDF report
const pdf = await zyndpay.transactions.exportPdf();const balances = await zyndpay.balances.getAll();
// { USDT_TRC20: '245.50', XOF: '150000', USD: '0' }const estimate = await zyndpay.payouts.estimate({
amount: '100.00',
currency: 'USDT_TRC20',
chain: 'TRON',
destinationAddress: 'TXyz1234567890abcdef1234567890abcde',
});
console.log(`Fee: ${estimate.fee} USDT`);
console.log(`Total deducted: ${estimate.totalDebited} USDT`);
console.log(`Sufficient balance: ${estimate.sufficient}`);Python SDK
The official Python SDK built on <ic>requests</ic> with a simple, synchronous interface.
pip install zyndpayfrom zyndpay import ZyndPay
client = ZyndPay(
api_key="zyp_live_sk_...",
webhook_secret=os.environ.get("ZYNDPAY_WEBHOOK_SECRET"),
)
payment = client.payins.create(
amount="100.00",
external_ref="order_123"
)
print(payment["address"])Python Quickstart
Accept your first USDT payment in under 5 minutes. This example uses Flask — swap in your framework of choice.
import os
from zyndpay import ZyndPay
from flask import Flask, request, jsonify
# 1. Initialize the SDK
client = ZyndPay(
api_key=os.environ["ZYNDPAY_API_KEY"], # zyp_live_sk_...
webhook_secret=os.environ["WEBHOOK_SECRET"],
)
# 2. Create a pay-in (generates a TRON deposit address for your customer)
payment = client.payins.create(
amount="100.00",
external_ref="order_123",
expires_in_seconds=3600,
success_url="https://yoursite.com/payment/success",
)
# Send payment["paymentUrl"] or payment["address"] to your customer
print("Deposit address:", payment["address"])
print("Payment page:", payment["paymentUrl"])
# 3. Handle webhook events
app = Flask(__name__)
@app.post("/webhook")
def handle_webhook():
# Always verify using the raw body — never parsed JSON
event = client.webhooks.verify(
request.get_data(),
request.headers.get("Zyndpay-Signature"),
)
if event["event"] == "payin.confirmed":
data = event["data"]
print(f"Payment confirmed: {data['amount']} USDT — {data['externalRef']}")
# Fulfill the order, unlock access, send receipt, etc.
elif event["event"] == "payin.expired":
print("Payment expired:", event["data"]["externalRef"])
return jsonify(received=True)
# 4. Check payment status anytime
status = client.payins.get(payment["transactionId"])
print("Status:", status["status"]) # AWAITING_PAYMENT → CONFIRMING → CONFIRMEDNew Resources (v1.5.0+)
As of SDK v1.5.0, the Python SDK supports: wallets.list(), fiat_destinations.create(), conversions.convert_between_wallets(), bulk_payments workflows, transactions.export(), and balances.get_all().
estimate = zyndpay.payouts.estimate(
amount='100.00',
destination_address='TXyz1234567890abcdef1234567890abcde',
)
print(f"Fee: {estimate['fee']} USDT")
print(f"Total deducted: {estimate['totalDebited']} USDT")
print(f"Sufficient: {estimate['sufficient']}")PHP SDK
The official PHP SDK with cURL-based HTTP client and webhook verification. Requires PHP 8.0+.
composer require zyndpay/zyndpay-php<?php
require_once 'vendor/autoload.php';
$client = new ZyndPay\ZyndPay('zyp_live_sk_...', [
'webhook_secret' => getenv('ZYNDPAY_WEBHOOK_SECRET'),
]);
// Create a payment
$payment = $client->payins->create([
'amount' => '100.00',
'externalRef' => 'order_123',
]);
echo $payment['address'];
// Verify a webhook
$event = $client->webhooks->verify(
$rawBody,
$_SERVER['HTTP_ZYNDPAY_SIGNATURE']
);PHP Quickstart
Accept your first USDT payment in under 5 minutes. This example uses plain PHP — adapt the webhook handler to your framework.
<?php
require_once 'vendor/autoload.php';
// 1. Initialize the SDK
$client = new ZyndPay\ZyndPay(
getenv('ZYNDPAY_API_KEY'), // zyp_live_sk_...
['webhook_secret' => getenv('WEBHOOK_SECRET')]
);
// 2. Create a pay-in (generates a TRON deposit address for your customer)
$payment = $client->payins->create([
'amount' => '100.00',
'externalRef' => 'order_123',
'expiresInSeconds' => 3600,
'successUrl' => 'https://yoursite.com/payment/success',
]);
// Send $payment['paymentUrl'] or $payment['address'] to your customer
echo "Deposit address: " . $payment['address'] . "\n";
echo "Payment page: " . $payment['paymentUrl'] . "\n";
// 3. Handle webhook events (in your webhook endpoint file)
$rawBody = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_ZYNDPAY_SIGNATURE'];
$event = $client->webhooks->verify($rawBody, $sigHeader);
switch ($event['event']) {
case 'payin.confirmed':
$data = $event['data'];
echo "Payment confirmed: {$data['amount']} USDT — {$data['externalRef']}\n";
// Fulfill the order, unlock access, send receipt, etc.
break;
case 'payin.expired':
echo "Payment expired: " . $event['data']['externalRef'] . "\n";
break;
}
http_response_code(200);
echo json_encode(['received' => true]);
// 4. Check payment status anytime
$status = $client->payins->get($payment['transactionId']);
echo "Status: " . $status['status'] . "\n"; // AWAITING_PAYMENT → CONFIRMING → CONFIRMEDRaw PHP implementation without the SDK. Produces the same HMAC-SHA256 check as the Node.js and Python examples above.
<?php
// IMPORTANT: read raw body before any JSON parsing
$payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_ZYNDPAY_SIGNATURE'] ?? '';
function verifyWebhook(string $payload, string $sigHeader, string $secret): bool {
$parts = [];
foreach (explode(',', $sigHeader) as $part) {
[$k, $v] = explode('=', $part, 2);
$parts[$k] = $v;
}
if (empty($parts['t']) || empty($parts['v1'])) {
return false;
}
// Reject events older than 5 minutes
if (abs(time() - (int) $parts['t']) > 300) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $payload, $secret);
// Timing-safe comparison to prevent timing attacks
return hash_equals($expected, $parts['v1']);
}
if (!verifyWebhook($payload, $sigHeader, getenv('ZYNDPAY_WEBHOOK_SECRET'))) {
http_response_code(400);
echo 'Invalid signature';
exit;
}
$event = json_decode($payload, true);
// Handle $event['event'] ...New Resources (v1.5.0+)
As of SDK v1.5.0, the PHP SDK supports: wallets->list(), fiatDestinations->create(), conversions->convertBetweenWallets(), bulkPayments workflows, transactions->export(), and balances->getAll().
$estimate = $zyndpay->payouts->estimate([
'amount' => '100.00',
'destinationAddress' => 'TXyz1234567890abcdef1234567890abcde',
]);
echo "Fee: " . $estimate['fee'] . " USDT\n";
echo "Total: " . $estimate['totalDebited'] . " USDT\n";WooCommerce Plugin
Accept USDT payments in your WordPress / WooCommerce store without writing a single line of code. The plugin generates deposit addresses, polls confirmation status, and auto-fulfills orders.
Download the plugin ZIP from the ZyndPay GitHub repository.
In WordPress admin go to Plugins → Add New → Upload Plugin.
Upload the ZIP and click Activate.
Navigate to WooCommerce → Settings → Payments → ZyndPay.
Enter your API key and webhook secret, then save.
Overview
The sandbox lets you test your entire integration without touching real funds or the TRON network. Use your <ic>zyp_test_sk_...</ic> key — the API is identical to production.
Testing Guide
Follow these steps to test your full payment flow end-to-end before going live.
# Using the sandbox API key (zyp_test_sk_...)
curl -X POST "https://api.zyndpay.io/v1/payments?sandbox=true" \
-H "X-Api-Key: zyp_test_sk_..." \
-H "Content-Type: application/json" \
-d '{
"amount": "100",
"externalRef": "test_order_001"
}'import { ZyndPay } from '@zyndpay/sdk';
const zyndpay = new ZyndPay({ apiKey: 'zyp_test_sk_...' });
// 1. Create a sandbox payin
const payin = await zyndpay.payins.create({
amount: '100',
sandbox: true,
externalRef: 'test_order_001',
});
// 2. Instantly simulate confirmation (fires the payin.confirmed webhook)
// simulate() returns an ack { message, transactionId }, not the payin —
// fetch the post-confirmation state with get().
await zyndpay.payins.simulate(payin.transactionId);
// 3. Read the final status
const tx = await zyndpay.payins.get(payin.transactionId);
console.log('Final status:', tx.status); // "CONFIRMED"# Reset all sandbox transactions (useful for clean test runs)
curl -X POST https://api.zyndpay.io/v1/sandbox/reset \
-H "X-Api-Key: zyp_test_sk_..."Simulate Payment
/v1/sandbox/payments/:id/simulateInstantly confirms a sandbox payin, fires the <ic>payin.confirmed</ic> webhook, and credits the sandbox balance — exactly like a real on-chain confirmation.
curl -X POST \
https://api.zyndpay.io/v1/sandbox/payments/{id}/simulate \
-H "X-Api-Key: zyp_test_sk_..."{
"success": true,
"data": {
"message": "Payin simulation triggered",
"transactionId": "f4b2cb0f-08ce-4408-88d4-0a678ca0aae2"
}
}Error Codes
All errors return the same JSON envelope. Use the <ic>code</ic> field to handle errors programmatically — the <ic>message</ic> is human-readable and may change between releases.
{
"success": false,
"error": {
"code": "AMOUNT_TOO_LOW",
"message": "Minimum payin amount is 1 USDT"
},
"statusCode": 400
}| Code | HTTP Status | Required | Description |
|---|---|---|---|
UNAUTHORIZED | 401 | required | API key is missing or invalid. Check that you are sending the X-Api-Key header and the key exists in your dashboard. |
FORBIDDEN | 403 | required | The API key does not have the required scope for this operation (<ic>INSUFFICIENT_SCOPE</ic>). Use a secret key (sk) not a publishable key (pk), and ensure the key was created with the required scope (e.g. <ic>wallets_write</ic> for whitelist endpoints). |
INVALID_API_KEY | 401 | required | The provided API key is invalid or has been revoked. |
INSUFFICIENT_SCOPE | 403 | required | The API key does not have permission for this action. |
TOTP_REQUIRED | 403 | required | Two-factor authentication is required to perform this action. |
TOTP_INVALID | 400 | required | The provided TOTP code is invalid or expired. |
BACKUP_CODE_INVALID | 400 | required | The backup code provided is invalid. |
EMAIL_UNVERIFIED | 403 | required | Account email address has not been verified. |
ACCOUNT_CLOSED | 403 | required | This merchant account has been closed. |
REGISTRATION_BLOCKED | 403 | required | New registrations are currently blocked for this account. |
SANDBOX_KEY_LIVE_REQUEST | 400 | required | You sent a sandbox key but the resource is a live transaction. Use a zyp_live_sk_... key for production requests. |
LIVE_KEY_SANDBOX_REQUEST | 400 | required | You sent sandbox: true with a live API key. Use a zyp_test_sk_... key for sandbox requests. |
IP_ALLOWLIST_SELF_LOCKOUT | 400 | required | This change would lock out your own IP address. |
MERCHANT_NOT_FOUND | 404 | required | The specified merchant was not found. |
MERCHANT_STATUS_INVALID | 403 | required | The merchant account is not in a valid state for this action. |
KYB_REQUIRED | 403 | required | This operation requires completed KYB (Know Your Business) verification. Complete KYB in your dashboard to unlock full limits. |
RATE_LIMIT_EXCEEDED | 429 | required | Too many requests. Check the Retry-After response header for the number of seconds to wait before retrying. |
RATE_LIMITED | 429 | required | Too many requests. Please slow down. |
MERCHANT_LIMIT_EXCEEDED | 403 | required | Merchant-level transaction limit has been exceeded. |
MONTHLY_LIMIT_EXCEEDED | 403 | required | Monthly limit reached. Resets on the first of next month. |
BALANCE_CAP_REACHED | 403 | required | Wallet balance cap has been reached. |
DAILY_CAP_EXCEEDED | 403 | required | Daily transaction cap has been exceeded. |
DAILY_CONVERSION_LIMIT | 403 | required | Daily conversion volume limit reached. |
DAILY_CONVERSION_COUNT_LIMIT | 403 | required | Daily conversion count limit reached. |
LIMIT_EXCEEDED_PAYLINKS | 403 | required | Maximum number of paylinks for this plan has been reached. |
LIMIT_EXCEEDED_WEBHOOKS | 403 | required | Maximum number of webhook endpoints has been reached. |
LIMIT_EXCEEDED_API_KEYS | 403 | required | Maximum number of API keys has been reached. |
LIMIT_EXCEEDED_TEAM_MEMBERS | 403 | required | Maximum number of team members has been reached. |
LIMIT_EXCEEDED_BULK_BATCH | 403 | required | Maximum number of items in a bulk batch has been reached. |
INVALID_ADDRESS | 400 | required | The TRON wallet address is malformed. Addresses must start with T and be 34 characters in base58 format. |
ADDRESS_IN_USE | 409 | required | This address is already in use by another transaction. |
ADDRESS_NOT_FOUND | 404 | required | The whitelistAddressId does not match any saved withdrawal address on your account. Add addresses in your dashboard under Withdrawal Addresses. |
NO_WHITELISTED_ADDRESS | 400 | required | No whitelisted withdrawal address found for this merchant. |
NO_PAYOUT_ADDRESS | 400 | required | No payout address configured for this merchant. |
ADDRESS_NOT_WHITELISTED | 403 | required | The destination address is not on your merchant whitelist. Add it in your dashboard before sending funds to it. |
ADDRESS_COOLDOWN | 403 | required | This address is in a cooldown period and cannot be used yet. |
ADDRESS_REMOVAL_DISABLED | 410 | required | Saved crypto recipients cannot be deleted. |
WHITELIST_VALIDATION_FAILED | 400 | required | Address whitelist validation failed. |
INVALID_CONTEXT | 400 | required | Invalid context provided for this operation. |
WALLET_NOT_FOUND | 404 | required | The specified wallet was not found. |
WALLET_DIRECT_WITHDRAW_DISABLED | 403 | required | Direct withdrawal from this wallet type is disabled. |
INSUFFICIENT_BALANCE | 400 | required | Your USDT balance is too low for the requested payout or withdrawal amount. Check your balance at GET /v1/wallets/balance. |
AMOUNT_TOO_SMALL | 400 | required | Amount is below the minimum for this operation (5 USDT for payins, 5 USDT for payouts/withdrawals). |
AMOUNT_TOO_LARGE | 400 | required | Amount exceeds the maximum allowed for this operation or your current compliance tier. |
INVALID_AMOUNT | 400 | required | The amount provided is not a valid number. |
INVALID_TRANSACTION_TYPE | 400 | required | The transaction type specified is not valid. |
INVALID_TRANSACTION_STATUS | 400 | required | The operation is not allowed in the current transaction status (e.g. trying to cancel a confirmed payout). |
REFUND_WINDOW_EXPIRED | 400 | required | The refund window for this transaction has expired. |
REFUND_EXCEEDS_AMOUNT | 400 | required | Refund amount exceeds the original transaction amount. |
REASON_NOTE_REQUIRED | 400 | required | A reason note is required for this action. |
CANNOT_CANCEL | 400 | required | This resource can no longer be cancelled (it has already been approved, broadcast, or completed). |
DUPLICATE_EXTERNAL_REF | 409 | required | A transaction with this externalRef already exists for your account. Use a unique reference per request, or omit it. |
MISSING_IDEMPOTENCY_KEY | 400 | required | This endpoint requires an Idempotency-Key header. Pass a unique UUID per request. |
IDEMPOTENCY_KEY_INVALID | 400 | required | The Idempotency-Key header is malformed. |
IDEMPOTENCY_KEY_MISMATCH | 409 | required | An existing request with the same Idempotency-Key has different parameters. Use a new key for a different request. |
CONFIG_MISSING | 500 | required | Required system configuration is missing. |
AML_BLOCKED | 403 | required | This transaction was flagged by our AML screening engine and cannot be processed. Contact [email protected]. |
AML_SCREENING_UNAVAILABLE | 503 | required | AML screening service is temporarily unavailable. |
COMPLIANCE_LIMIT_REACHED | 403 | required | Your monthly transaction volume has reached the compliance limit for your current tier. Complete KYB to increase your limits. |
CONVERSION_NOT_ALLOWED | 403 | required | Conversion between these currencies is not permitted. |
RATE_LOCK_EXPIRED | 400 | required | The FX rate lock has expired. Request a new rate. |
RATE_LOCK_NOT_FOUND | 404 | required | The specified rate lock was not found. |
RATE_LOCK_ALREADY_USED | 409 | required | This rate lock has already been used. |
RATE_UNAVAILABLE | 503 | required | Exchange rate is currently unavailable. Try again shortly. |
RATE_STALE | 400 | required | The exchange rate is stale. Refresh and try again. |
FX_UNAVAILABLE | 503 | required | FX conversion service is temporarily unavailable. |
INVALID_CONVERSION_PAIR | 400 | required | The specified currency conversion pair is not supported. |
NEGATIVE_REVENUE | 400 | required | This conversion would result in negative revenue. |
MISSING_PHONE | 400 | required | Customer phone number is required for this payment method. |
MISSING_OPERATOR | 400 | required | Mobile money operator code is required. |
MISSING_BANK_DETAILS | 400 | required | Bank account details are required. |
MISSING_MOBILE_MONEY_FIELDS | 400 | required | Required mobile money fields are missing. |
MISSING_BANK_FIELDS | 400 | required | Required bank transfer fields are missing. |
FIAT_DESTINATION_REQUIRED | 400 | required | A fiat destination must be configured before withdrawing. |
FIAT_DESTINATION_NOT_FOUND | 404 | required | The specified fiat destination was not found. |
FIAT_DESTINATION_INVALID | 400 | required | The fiat destination configuration is invalid. |
REFUND_RAIL_NOT_AVAILABLE | 400 | required | Refund is not available via the original payment rail. |
WITHDRAWAL_NOT_FIAT | 400 | required | This operation requires a fiat withdrawal. |
WITHDRAWAL_APPROVE_NOT_SUPPORTED_FOR_FIAT | 400 | required | Fiat withdrawals cannot be approved via the standard approve endpoint — they would be routed to the on-chain TRON broadcast path. Use the complete-fiat endpoint with a providerRef once the bank wire has been executed. |
WITHDRAWAL_NOT_PENDING_REVIEW | 400 | required | The withdrawal is not in PENDING_REVIEW status. |
BENEFICIARY_REQUIRED | 400 | required | A beneficiary must be specified for this payout. |
BENEFICIARY_INVALID | 400 | required | The beneficiary details are invalid. |
BENEFICIARY_NOT_FOUND | 404 | required | The specified beneficiary was not found. |
BENEFICIARY_ALREADY_EXISTS | 409 | required | A beneficiary with these details already exists. |
BENEFICIARY_IN_USE | 409 | required | This beneficiary is in use by an active transaction. |
BENEFICIARY_REJECTED | 403 | required | This beneficiary has been rejected by compliance. |
BENEFICIARY_COOLDOWN | 403 | required | This beneficiary is in a cooldown period. |
BENEFICIARY_NOT_VERIFIED | 403 | required | This beneficiary has not been verified yet. |
SELF_OWNED_REQUIRED | 400 | required | A self-owned beneficiary is required for this operation. |
MAX_BENEFICIARIES_REACHED | 403 | required | Maximum number of beneficiaries has been reached. |
THIRD_PARTY_NOT_ALLOWED | 403 | required | Third-party beneficiaries are not allowed for this operation. |
CARD_PAYMENTS_DISABLED | 403 | required | Card payments are not enabled for this merchant. |
MOBILE_MONEY_PAYMENTS_DISABLED | 403 | required | Mobile money payments are not enabled for this merchant. |
USDT_PAYMENTS_DISABLED | 403 | required | USDT payments are not enabled for this merchant. |
NO_METHODS_ENABLED | 403 | required | No payment methods are enabled for this paylink. |
FEE_NOT_CONFIGURED | 500 | required | Fee configuration is missing for this payment method. |
PAYMENT_METHOD_NOT_ACCEPTED | 400 | required | The specified payment method is not accepted for this transaction. |
PROVIDER_INITIATE_FAILED | 502 | required | The payment provider failed to initiate the transaction. |
PROVIDER_MISSING_URL | 502 | required | The payment provider did not return a redirect URL. |
OPERATOR_NOT_SUPPORTED | 400 | required | The mobile money operator is not supported in this region. |
INVALID_STATE | 400 | required | The transaction is not in the correct state for this action. |
OTP_INVALID | 400 | required | The OTP code provided is incorrect. |
OTP_EXPIRED | 400 | required | The OTP code has expired. Request a new one. |
OTP_NOT_APPLICABLE | 400 | required | OTP submission is not applicable for this transaction. |
MOMO_PROVIDER_ERROR | 502 | required | The mobile money provider returned an error. |
CUSTOMER_PHONE_REQUIRED | 400 | required | Customer phone number is required for mobile money payments. |
CANNOT_CHANGE_CURRENCY_AFTER_PRODUCTS | 400 | required | Currency cannot be changed after products have been added. |
PAYLINK_EMPTY | 400 | required | The paylink has no products configured. |
MARKETPLACE_DISABLED | 403 | required | Marketplace / Connect features are not enabled for this account. |
NOT_A_PLATFORM_MERCHANT | 403 | required | This merchant is not a platform merchant. |
SPLIT_RULE_NOT_FOUND | 404 | required | The specified split rule was not found. |
SPLIT_RULE_IN_USE | 409 | required | This split rule is in use and cannot be deleted. |
SPLIT_RULE_INVALID_BPS_SUM | 400 | required | Split rule basis points do not sum to 10000. |
SPLIT_RULE_MISSING_ZYNDPAY_RECIPIENT | 400 | required | Split rule is missing the ZyndPay fee recipient. |
SPLIT_RULE_MISSING_PLATFORM_RECIPIENT | 400 | required | Split rule is missing the platform merchant recipient. |
SPLIT_RULE_MISSING_SUB_MERCHANT_RECIPIENT | 400 | required | Split rule is missing the sub-merchant recipient. |
SPLIT_RULE_INVALID_ZYNDPAY_BPS | 400 | required | ZyndPay fee basis points in this split rule are below minimum. |
SUB_MERCHANT_NOT_CONNECTED | 403 | required | This sub-merchant is not connected to your platform. |
SUB_MERCHANT_ALREADY_CONNECTED | 409 | required | This sub-merchant is already connected to a platform. |
SUB_MERCHANT_KYB_REQUIRED | 403 | required | The sub-merchant must complete KYB before this action. |
SUB_MERCHANT_SUSPENDED | 403 | required | The sub-merchant account is suspended. |
SUB_MERCHANT_AML_FLAGGED | 403 | required | The sub-merchant has been flagged by AML screening. |
SUB_MERCHANT_INVITATION_NOT_FOUND | 404 | required | The sub-merchant invitation was not found. |
SUB_MERCHANT_INVITATION_EXPIRED | 400 | required | The sub-merchant invitation has expired. |
SUB_MERCHANT_INVITATION_ALREADY_USED | 409 | required | This invitation has already been accepted. |
SUB_MERCHANT_HAS_PENDING_BALANCE | 409 | required | The sub-merchant has a pending balance that must be settled first. |
SPLIT_PAYMENT_NOT_FOUND | 404 | required | The specified split payment was not found. |
SPLIT_PAYMENT_ALREADY_REVERSED | 409 | required | This split payment has already been reversed. |
IMPORT_VALIDATION_FAILED | 400 | required | One or more items in the bulk import failed validation. |
VALIDATION_ERROR | 400 | required | Request body failed validation. The details field lists each invalid field and reason. |
NOT_FOUND | 404 | required | The requested resource does not exist or does not belong to your merchant account. |
DUPLICATE_RESOURCE | 409 | required | A resource with this unique value already exists (e.g. duplicate email, slug, or field). |
CONFLICT | 409 | required | A conflict occurred with an existing resource. |
BAD_REQUEST | 400 | required | The request was malformed or contained invalid parameters. |
INVALID_WEBHOOK_SIGNATURE | 400 | required | Webhook signature verification failed. Check your webhook secret and that you are using the raw request body. |
INTERNAL_ERROR | 500 | required | Unexpected server error. Include the requestId from the response when contacting [email protected]. |
SERVICE_UNAVAILABLE | 503 | required | The service is temporarily unavailable. Try again shortly. |
Rate Limits
Limits are enforced per API key. Exceeded requests receive a <ic>429</ic> response with a <ic>Retry-After</ic> header.
| Endpoint | Rate Limit | Required | Description |
|---|---|---|---|
Default | 100/60s | required | Global rate limit per API key. |
POST /payments | 30/60s | required | Payin creation (POST /payments) per merchant. |
POST /payout | 10/60s | required | Payout creation (POST /payout) per merchant. |
POST /withdrawals | 5/60s | required | Withdrawal requests per merchant. |