DocsIntroduction

ZyndPay API Reference

API v1 · USDT · Card · Mobile Money

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.

Base URLhttps://api.zyndpay.io/v1
json
{
  "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.

bash
curl https://api.zyndpay.io/v1/payments \
  -H "X-Api-Key: zyp_live_sk_..."
ParameterTypeRequiredDescription
zyp_live_sk_*stringrequiredLive secret key — full API access. Never expose client-side.
zyp_live_pk_*stringoptionalLive publishable key — limited read access, safe for browsers.
zyp_test_sk_*stringrequiredSandbox secret key — identical to live but no real transactions.
zyp_test_pk_*stringoptionalSandbox publishable key.
zyp_rk_*stringoptionalRestricted key — scoped to specific operations only.
Warning
Never embed <ic>zyp_live_sk_</ic> or <ic>zyp_test_sk_</ic> in client-side code, mobile apps, or public repositories.

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.

Tip
Not just USDT — ZyndPay also accepts bank card and mobile money payments in XOF. See Card Payment and Mobile Money for details.
typescript
// 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"
Tip
Not ready for real funds? Use a zyp_test_sk_... sandbox key and add sandbox: true to the payin request. Then call POST /v1/sandbox/payments/:id/simulate to instantly confirm it — no blockchain needed.

Going Live

Before accepting real payments, work through this checklist. Most items take under 5 minutes each.

1
Complete KYB
Submit your business documents in the dashboard. All merchants must complete KYB verification and sign the MSA before processing live transactions.
2
Switch to live API keys
Replace your zyp_test_sk_... keys with zyp_live_sk_... keys. Live keys create real on-chain transactions.
3
Register a webhook endpoint
Add your production webhook URL in the dashboard and store the webhook secret in your environment variables.
4
Test end-to-end in production
Send a real $1 USDT payment through your integration. Verify the webhook fires and your order logic executes correctly.
5
Set up error monitoring
Subscribe to webhook delivery failure alerts in the dashboard. Implement retry logic for failed webhook processing.
6
Review rate limits
Default: 30 payins/min, 10 payouts/min, 5 withdrawals/min. Contact [email protected] if you need higher limits.
Tip
Start with a single test transaction using a real live key before announcing to customers — this catches environment variable issues that sandbox testing can't reveal.

Payins

Create a Payin

POST/v1/payments

Generates a unique TRON wallet address for your customer to send USDT to. The address expires after <ic>expiresInSeconds</ic> seconds (default 30 minutes).

ParameterTypeRequiredDescription
amountstringrequiredAmount in USDT as a decimal string (e.g. "100.00"). Minimum: "1.00".
externalRefstringoptionalYour order or reference ID (optional). Must be unique per merchant if provided.
expiresInSecondsnumberoptionalSeconds until the address expires. Minimum: 900. Default: 1800 (30 minutes).
successUrlstringoptionalURL to redirect the customer after a successful payment.
cancelUrlstringoptionalURL to redirect the customer if the payment expires or is cancelled.
metadataobjectoptionalArbitrary key-value pairs stored with the payment and included in webhooks.
paymentMethodstringoptionalPayment rail. "USDT_TRC20" (default), "CARD", or "MOBILE_MONEY".
customerNamestringoptionalCustomer full name. Required for card payments.
customerEmailstringoptionalCustomer email address. Required for card payments.
bash
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"
  }'
json
{
  "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

POST/v1/payments

Collect 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.

ParameterTypeRequiredDescription
paymentMethodstringrequiredMust be "MOBILE_MONEY" for a mobile money pay-in.
amountstringrequiredAmount in XOF as a string (e.g. "1000"). XOF has no decimals — pass an integer-valued string. Minimum: "500".
customerPhonestringrequiredCustomer phone number in E.164 format (e.g. "+22670123456"). Required for MOBILE_MONEY.
operatorCodestringoptionalOverride the mobile-money operator detected from the phone prefix. Must be one of ORANGE_BF, MOOV_BF. Ignored for non-MOBILE_MONEY rails.
externalRefstringoptionalYour order or reference ID (optional). Must be unique per merchant if provided.
metadataobjectoptionalArbitrary 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.

bash
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.

json
{
  "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"
  }
}
json
{
  "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

POST/v1/payments/:id/submit-otp
POST/v1/payins/:id/submit-otp

Submits 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.

ParameterTypeRequiredDescription
otpstringrequiredThe 4-8 digit code the customer received by SMS. Only applies to OTP-mode operators.
bash
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"
  }'
json
{
  "success": true,
  "data": {
    "transactionId": "pay_momo_abc123",
    "status": "AWAITING_PAYMENT"
  }
}

Get a Payin

GET/v1/payments/:id
json
{
  "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 funds
PENDINGAddress generated, no funds received yet
CONFIRMINGFunds received, waiting for 5 confirmations
CONFIRMED5+ confirmations — balance credited
UNDERPAIDLess than expected received
OVERPAIDMore than expected received
EXPIREDAddress expired without payment
FAILEDPayment failed — no funds received

Card 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.

Warning
Card payments must be enabled for your account in the dashboard before use. The settlement currency is XOF.
POST/v1/payments
ParameterTypeRequiredDescription
amountstringrequiredAmount 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.
paymentMethodstringrequiredPayment rail. "USDT_TRC20" (default), "CARD", or "MOBILE_MONEY".
customerNamestringrequiredCustomer full name. Required for card payments.
customerEmailstringrequiredCustomer email address. Required for card payments.
Note
The response includes hostedPaymentUrl — redirect your customer to this URL to complete the card payment. The status starts as AWAITING_PAYMENT and transitions to CONFIRMED (or FAILED) via webhook.
Note
Card processing fees are deducted from the received amount. currency must be XOF.
bash
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"
  }'
typescript
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

GET/v1/payments/:id
bash
curl https://api.zyndpay.io/v1/payments/TXN_ID \
  -H "X-Api-Key: YOUR_API_KEY"

List Payins

GET/v1/payments
ParameterTypeRequiredDescription
pagenumberoptionalPage number. Default: 1.
limitnumberoptionalResults per page. Default: 20. Max: 100.
statusstringoptionalFilter 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>.
currencystringoptionalFilter by currency (e.g. USDT_TRC20).
bash
curl https://api.zyndpay.io/v1/payments \
  -H "X-Api-Key: YOUR_API_KEY"
python
payins = client.payins.list(limit=20, status="CONFIRMED")
for p in payins["items"]:
    print(p["transactionId"], p["status"])
json
{
  "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

python
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
<?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'];

Payment Links
POST/v1/paylinks

Creates 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.

ParameterTypeRequiredDescription
namestringoptionalInternal name for the paylink shown in the merchant dashboard. Optional, never shown to the customer.
typestringrequiredPayment link type: FIXED (default), VARIABLE, or RECURRING.
productsarrayrequiredArray of products. At least one product is required.
products[].namestringrequiredProduct name displayed to the customer.
products[].pricestringrequiredPrice in USDT as a decimal string.
products[].productTypestringrequiredPHYSICAL (default) or DIGITAL.
products[].digitalUrlstringoptionalExternal URL for digital product delivery (Google Drive, Mega, etc.).
collectEmailstringoptionalCustomer email collection: HIDDEN (default), OPTIONAL, or REQUIRED.
collectNamestringoptionalCustomer name collection: HIDDEN, OPTIONAL, or REQUIRED.
brandColorstringoptionalHex color for branded checkout page (e.g. #635BFF).
successUrlstringoptionalURL to redirect the customer after a successful payment.
recurringIntervalstringoptionalBilling interval for recurring paylinks: WEEKLY, MONTHLY, or YEARLY.
bash
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"
  }'
json
{
  "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/v1/paylinks/:id
json
{
  "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/v1/paylinks
ParameterTypeRequiredDescription
pagenumberoptionalPage number. Default: 1.
limitnumberoptionalResults per page. Default: 20. Max: 100.
json
{
  "success": true,
  "data": {
    "items": [ ... ],
    "meta": {
      "page": 1,
      "limit": 20,
      "total": 134,
      "totalPages": 7,
      "hasNext": true,
      "hasPrev": false
    }
  }
}
POST/v1/pay/link/:slug/checkout

Public endpoint (no auth required). Creates an order and payin from a payment link. The customer receives a TRON deposit address to send USDT to.

ParameterTypeRequiredDescription
itemsarrayrequiredArray of product IDs and quantities to purchase.
customerEmailstringoptionalCustomer email address (required for RECURRING and digital products).
customerNamestringoptionalCustomer name (optional).
bash
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"
  }'
json
{
  "success": true,
  "data": {
    "orderId": "ord_xyz789",
    "transactionId": "cma2abc9g0002yz6l0def",
    "address": "TRXabc123def456ghi789jkl",
    "amount": "49.99",
    "expiresAt": "2026-03-08T11:00:00.000Z"
  }
}

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.

python
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"])
php
$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'];
POST/v1/paylinks/simulate
bash
curl -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"}'
GET/v1/paylinks/:id/stats
bash
curl https://api.zyndpay.io/v1/paylinks/PAYLINK_ID/stats \
  -H "X-Api-Key: YOUR_API_KEY"

Retrieve all orders placed through a payment link. Each order tracks the customer, amount paid, payment method, and status.

GET/v1/paylinks/:id/orders
bash
curl https://api.zyndpay.io/v1/paylinks/PAYLINK_ID/orders \
  -H "X-Api-Key: YOUR_API_KEY"
Tip
GET /v1/paylinks/:id/orders/export — download all orders as a CSV file.

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.

POST/v1/paylinks/:id/promo-codes
ParameterTypeRequiredDescription
codestringrequiredThe customer-facing code, normalized to uppercase by the API (e.g. `"tabaski"` → `"TABASKI"`).
discountTypestringrequired`PERCENT` or `FIXED`. `PERCENT` applies `discountValue`% off the subtotal; `FIXED` subtracts a flat amount in the paylink's currency.
discountValuestringrequiredFor `PERCENT`, the percentage as a decimal string (e.g. `"15"` for 15%). For `FIXED`, the amount in the paylink's currency.
maxUsesintegeroptionalMaximum total redemptions across all customers. Omit for unlimited uses.
expiresAtstringoptionalISO 8601 timestamp after which the code is no longer redeemable (e.g. `"2026-12-31T23:59:59Z"`). Omit for no time limit.
bash
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"
  }'
Tip
Use `expiresAt` for time-limited promotions (Tabaski, end-of-month sale, Black Friday) so the discount stops applying automatically. Pair with `maxUses` for hybrid limits like "first 50 customers OR until end of week".
GET/v1/paylinks/:id/promo-codes
PATCH/v1/paylinks/:id/promo-codes/:codeId
DELETE/v1/paylinks/:id/promo-codes/:codeId

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.

POST/v1/pay/link/:slug/apply-promo
Note
No authentication — designed to be called from the customer's browser. The same redemption check fires again at checkout submission, so race conditions (code hits its limit between preview and submit) are caught defensively.
bash
# 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"}'
json
{
  "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.codeWhenRequiredDescription
PROMO_CODE_INVALIDlookupoptionalCode doesn't exist on this paylink.
PROMO_CODE_INACTIVEstateoptionalMerchant has toggled the code off via `PATCH .../promo-codes/:codeId` with `isActive: false`.
PROMO_CODE_EXPIREDtimeoptionalPast `expiresAt`. Refresh the cart UI — `expiresAt` is included on the list response.
PROMO_CODE_USAGE_LIMIT_REACHEDcountoptional`useCount >= maxUses`. The code has been redeemed by enough customers.

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.

POST/v1/paylinks/templates
GET/v1/paylinks/templates
DELETE/v1/paylinks/templates/:id
bash
curl -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"
    }
  }'
Tip
POST /v1/paylinks/:id/save-as-template — snapshot an existing paylink's configuration as a new template.

Upload a cover image for the paylink checkout page, and per-product images for each item. Uploads use multipart/form-data.

Note
Image uploads require multipart/form-data. In TypeScript use the SDK's uploadCoverImage() which handles FormData. In Python and PHP pass a local file path — the SDK reads and sends it.
POST/v1/paylinks/:id/cover-image
DELETE/v1/paylinks/:id/cover-image
POST/v1/paylinks/:id/products/:productId/image
Tip
POST /v1/paylinks/:id/products/import-csv — bulk-import products from a CSV file instead of defining them one by one.
POST/v1/paylinks/:id/products/import-csv

Payouts

Create a Payout

POST/v1/payout

Use <ic>POST /transfers</ic> for new outbound money movement. The legacy payout endpoint remains available for compatibility with existing API integrations.

Note
<strong>Canonical API:</strong> Transfers are now the single merchant outflow product. Legacy payouts and withdrawals are compatibility routes backed by the same outbound-transfer policy.
ParameterTypeRequiredDescription
amountstringrequiredAmount to send (for example, "50.00"). The applicable fee is calculated from your account terms and returned before execution.
destinationAddressstringrequiredTRON (TRC20) wallet address of the recipient. Must match the format T followed by 33 base58 characters.
currencystringoptionalCurrency to send. Default: USDT_TRC20.
chainstringoptionalBlockchain network. Default: TRON.
externalRefstringoptionalYour internal reference ID for this payout (e.g. vendor invoice number).
metadataobjectoptionalArbitrary key-value pairs stored with the payout.
bash
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"
  }'
json
{
  "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.

POST/v1/payout/estimate
ParameterTypeRequiredDescription
amountstringrequiredAmount in USDT to estimate for.
currencystringoptionalCurrency (default: USDT_TRC20).
chainstringoptionalBlockchain (default: TRON).
destinationAddressstringrequiredDestination TRON address. Required — the estimate validates the address and runs an AML pre-check.
bash
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"}'
typescript
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
estimate = client.payouts.estimate(
    amount="100.00",
    destination_address="TRON_ADDRESS",
)
print(f"Fee: {estimate['fee']} USDT")
print(f"Total debited: {estimate['totalDebited']} USDT")
php
$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

python
payout = client.payouts.create(
    amount="100.00",
    destination_address="TRON_ADDRESS",
    idempotency_key="payout_vendor_apr_001",
)
print(payout["id"], payout["status"])
php
$payout = $client->payouts->create([
    'amount' => '100.00',
    'destinationAddress' => 'TRON_ADDRESS',
], idempotencyKey: 'payout_vendor_apr_001');

echo $payout['id'];

Get a Payout

GET/v1/payout/:id
json
{
  "success": true,
  "data": {
    "transactionId": "cma2abc9g0002yz6l0def5678",
    "status": "PROCESSING",
    "processingFee": "1.50",
    "requiresManualApproval": false,
    "currentPayinFee": "1%",
    "currentTier": "flat"
  }
}
PENDINGAwaiting manual approval (amount > $50K)
PROCESSINGApproved, preparing broadcast
BROADCASTSubmitted to TRON network
CONFIRMEDConfirmed on-chain
FAILEDBroadcast failed — balance refunded
CANCELLEDCancelled before broadcast

List Payouts

GET/v1/payout
json
{
  "success": true,
  "data": {
    "items": [ ... ],
    "meta": {
      "page": 1,
      "limit": 20,
      "total": 42,
      "totalPages": 3,
      "hasNext": true,
      "hasPrev": false
    }
  }
}

Transfers

Request a Withdrawal

POST/v1/withdrawals

Transfers 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.

Note
Saved destinations must be registered before they can receive transfers. If <ic>whitelistAddressId</ic> is omitted, ZyndPay uses your account's primary saved crypto destination.
ParameterTypeRequiredDescription
amountstringrequiredAmount to withdraw (for example, "500.00"). The applicable fee is calculated from your account terms and shown before execution.
whitelistAddressIdstringoptionalID of the saved withdrawal address to use. Defaults to your primary address if omitted.
bash
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"
  }'
json
{
  "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

GET/v1/withdrawals
bash
curl https://api.zyndpay.io/v1/withdrawals \
  -H "X-Api-Key: YOUR_API_KEY"
python
withdrawal = client.withdrawals.create(
    amount="50.00",
    idempotency_key="withdrawal_001",
)
print(withdrawal["id"], withdrawal["status"])

Get a Withdrawal

GET/v1/withdrawals/:id
bash
curl https://api.zyndpay.io/v1/withdrawals/WD_ID \
  -H "X-Api-Key: YOUR_API_KEY"
json
{
  "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 processing
BROADCASTSubmitted to TRON network
CONFIRMEDConfirmed on-chain
REJECTEDRejected by admin review
FAILEDBroadcast failed — balance refunded
CANCELLEDCancelled before broadcast
Note
Every withdrawal request is reviewed by the ZyndPay compliance team before approval.

Cancel a Withdrawal

DELETE/v1/withdrawals/:id

Cancels 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.

bash
curl -X DELETE https://api.zyndpay.io/v1/withdrawals/WD_ID \
  -H "X-Api-Key: YOUR_API_KEY"

Bulk Payments

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.

Note
Batch state machine: DRAFT → VALIDATED → PROCESSING → COMPLETED (or PARTIALLY_COMPLETED if some items fail).

Create Batch

Create an empty batch. Optionally set a label for internal reference.

POST/v1/bulk-payments
ParameterTypeRequiredDescription
labelstringoptionalInternal label for this batch (optional).
bash
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.

POST/v1/bulk-payments/:id/items
ParameterTypeRequiredDescription
itemsarrayrequiredArray of payment items. Each item: walletAddress, amount, recipientName (optional), reference (optional).
Tip
POST /v1/bulk-payments/:id/import — upload a CSV or XLSX file. Download the template first: GET /v1/bulk-payments/template.
bash
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.

Note
POST /v1/bulk-payments/:id/validate then POST /v1/bulk-payments/:id/execute. Execution is asynchronous — monitor via GET /v1/bulk-payments/:id.
bash
curl -X POST https://api.zyndpay.io/v1/bulk-payments/batch_abc/validate \
  -H "X-Api-Key: zyp_live_sk_..."
bash
curl -X POST https://api.zyndpay.io/v1/bulk-payments/batch_abc/execute \
  -H "X-Api-Key: zyp_live_sk_..."
typescript
// 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
python
# 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"])
php
// 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.

POST/v1/bulk-payments/:id/retry

Cancel 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.

POST/v1/bulk-payments/:id/cancel

Monitor

Poll batch status and per-item results. Export a CSV report when complete.

GET/v1/bulk-payments/:id
GET/v1/bulk-payments/:id/export
bash
curl https://api.zyndpay.io/v1/bulk-payments/batch_abc \
  -H "X-Api-Key: zyp_live_sk_..."

Wallets & Conversions

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.

Note
USDT wallet: receives USDT payins, sends USDT payouts, withdraws to TRON addresses. XOF wallet: receives card and mobile money payins, sends XOF payouts, withdraws to mobile money or bank accounts via fiat destinations.

List Wallets

Returns one wallet per (currency, rail) pair. Use the wallet id when calling conversions or withdrawals.

GET/v1/merchants/wallets
bash
curl https://api.zyndpay.io/v1/merchants/wallets \
  -H "X-Api-Key: YOUR_API_KEY"
typescript
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);
python
wallets = client.wallets.list()
for w in wallets:
    print(f"{w['currency']} ({w['rail']}): {w['balance']}")
php
$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.

Note
Whitelist endpoints require an API key with the <ic>wallets_write</ic> scope (for mutations) or <ic>wallets_read</ic> scope (for listing). Create a new key under Dashboard → API Keys and select the <strong>Wallets</strong> scope group.
Warning
A 24-hour security cooldown applies after every new address is added — whether through the dashboard or the API. During this window the address exists in your whitelist but cannot be used as a withdrawal destination. Each response includes an <ic>availableAt</ic> timestamp indicating when the address becomes eligible. Plan accordingly: pre-whitelist addresses at customer onboarding or deposit time rather than at the moment of withdrawal.

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.

POST/v1/wallets/whitelist
bash
curl -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"]
  }'
typescript
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.

POST/v1/wallets/whitelist/bulk
bash
curl -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"]
  }'
typescript
// 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.

PATCH/v1/wallets/whitelist/:id/contexts
bash
curl -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.

GET/v1/wallets/whitelist
bash
curl 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.

Note
Fiat destination endpoints require an API key with the <ic>fiat_destinations_write</ic> scope (create/update/delete) or <ic>fiat_destinations_read</ic> scope (list). Create a new key under Dashboard → API Keys and select the <strong>Fiat Destinations</strong> scope group.
Warning
A 24-hour BCEAO compliance cooldown applies after registering a new destination before it can be used for withdrawals.
GET/v1/merchants/fiat-destinations
POST/v1/merchants/fiat-destinations
PATCH/v1/merchants/fiat-destinations/:id
DELETE/v1/merchants/fiat-destinations/:id

For MOMO: kind, label, momoOperator (ORANGE_BF, MOOV_BF), momoPhone (E.164 format).

For BANK: kind, label, bankName, bankAccountName, bankIban or bankAccountNumber, bankCode.

bash
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
  }'
typescript
// 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.

Note
Use the same wallet pair for preview and conversion. The canonical write path is <ic>POST /v1/conversions/wallet</ic> with source wallet ID, destination wallet ID, and a decimal source amount.
GET/v1/conversions/wallet/preview
bash
curl "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

GET/v1/conversions
bash
curl 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.

Tip
Pass an Idempotency-Key header to prevent duplicate conversions if you retry on network failure.
POST/v1/conversions/wallet
ParameterTypeRequiredDescription
fromWalletIdstringrequiredSource wallet ID (from list wallets).
toWalletIdstringrequiredDestination wallet ID.
fromAmountstringrequiredAmount to convert from the source wallet.
typescript
// 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}`);
python
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"])

Transactions

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.

GET/v1/transactions/:id
bash
curl 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.

GET/v1/transactions
ParameterTypeRequiredDescription
typestringoptionalFilter by type: PAYIN, PAYOUT, WITHDRAWAL.
statusstringoptionalFilter by status.
currencystringoptionalFilter by currency (e.g. USDT_TRC20).
fromstringoptionalStart date (ISO 8601).
tostringoptionalEnd date (ISO 8601).
pagenumberoptionalPage number. Default: 1.
limitnumberoptionalResults per page. Default: 20. Max: 100.
bash
curl "https://api.zyndpay.io/v1/transactions?limit=20&type=PAYIN" \
  -H "X-Api-Key: YOUR_API_KEY"
typescript
const txs = await zyndpay.transactions.list({
  type: 'PAYIN',
  status: 'CONFIRMED',
  from: '2026-01-01',
  to: '2026-04-30',
});
console.log(`${txs.total} transactions found`);
python
txns = client.transactions.list(limit=20, type="PAYIN")
for t in txns["items"]:
    print(t["id"], t["type"], t["status"], t["amountRequested"])
php
$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.

Note
<ic>GET /v1/transactions/export</ic> starts a background CSV job and returns <ic>{ jobId, status }</ic>. Poll <ic>GET /v1/exports/:jobId/status</ic> until <ic>status: "completed"</ic>, then fetch <ic>GET /v1/exports/:jobId/download</ic> (download URL is valid for 10 minutes). <ic>GET /v1/transactions/export/pdf</ic> returns a PDF report synchronously (<ic>application/pdf</ic>).
GET/v1/transactions/export
GET/v1/transactions/export/pdf
bash
curl "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
typescript
// 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();

Webhooks

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.

Tip
Your endpoint must return a <ic>2xx</ic> status within 10 seconds. Failed deliveries are retried with exponential backoff up to 5 times over ~2 hours.
json
{
  "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.

Zyndpay-Signature
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.

javascript
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 });
});
Warning
Always read the raw request body before any JSON parsing. Middleware that parses JSON automatically (e.g. express.json()) will corrupt the body and cause verification to fail. Use express.raw({ type: 'application/json' }) for the webhook route.

Webhook Endpoints

Manage your webhook endpoints programmatically instead of (or in addition to) the dashboard UI.

Warning
Creating an endpoint returns the <ic>secret</ic> field (prefixed <ic>whs_</ic>) once — store it immediately in your environment variables. The response also includes <ic>secretHint</ic> (last 4 chars, safe to display) and <ic>secretHash</ic> (server-side reference). The full <ic>secret</ic> cannot be retrieved again.
POST/v1/webhooks/endpoints
ParameterTypeRequiredDescription
urlstringrequiredHTTPS URL that ZyndPay will POST events to.
eventsarrayrequiredArray of event names to subscribe to (e.g. ["payin.confirmed", "payout.failed"]).
retryConfigobjectoptionalOptional 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.
GET/v1/webhooks/endpoints
PATCH/v1/webhooks/endpoints/:id
DELETE/v1/webhooks/endpoints/:id
PATCH/v1/webhooks/endpoints/:id/rotate-secret
Note
PATCH /v1/webhooks/endpoints/:id/rotate-secret — generates a new signing secret. The old secret remains valid for 24 hours to allow zero-downtime rotation.
PATCH/v1/webhooks/endpoints/:id/reactivate
Note
PATCH /v1/webhooks/endpoints/:id/reactivate — re-enable an endpoint that was auto-suspended after too many consecutive delivery failures.
Tip
GET /v1/webhooks/deliveries — view delivery history, response codes, and retry count for each event.
typescript
const 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

Note
payin.* events fire for payments you create via POST /v1/payments (customer checkout). deposit.* events fire for direct USDT transfers to your wallet address that were not created through the API. Subscribe to both if your integration also accepts manual or over-the-counter transfers.
EventTypeRequiredDescription
payin.createdeventrequiredFired when a new payin is created and a deposit address is generated.
payin.confirmingeventrequiredFired when funds are received and awaiting 5 block confirmations.
payin.confirmedeventrequiredFired 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.expiredeventrequiredFired when a payin address expires without receiving the expected amount.
payin.failedeventrequiredFired 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.underpaideventrequiredFired when a payin receives less than the requested amount.
payin.overpaideventrequiredFired when a payin receives more than the requested amount.
deposit.confirmedeventrequiredFired when a wallet deposit reaches 20 on-chain confirmations. Merchant balance is credited.
deposit.failedeventrequiredFired when a wallet deposit fails to confirm.
deposit.overpaideventrequiredFired when a wallet deposit receives more than the expected amount.
deposit.underpaideventrequiredFired when a wallet deposit receives less than the expected amount.
payout.requestedeventrequiredFired when a new payout is requested and queued for compliance review. Emitted alongside the legacy `withdrawal.requested` event for the same lifecycle.
payout.approvedeventrequiredFired when a payout passes review and is approved for on-chain broadcast. Emitted alongside the legacy `withdrawal.approved` event for the same lifecycle.
payout.broadcasteventrequiredFired when a payout transaction is signed and broadcast to the TRON network.
payout.confirmedeventrequiredFired when a payout reaches 20 on-chain confirmations.
payout.failedeventrequiredFired when a payout broadcast fails. Amount is refunded to merchant balance.
withdrawal.requestedeventrequiredFired when a new withdrawal is requested and queued for compliance review.
withdrawal.broadcasteventrequiredFired when a withdrawal is signed and broadcast to TRON.
withdrawal.approvedeventrequiredFired when a withdrawal passes review and is approved for on-chain broadcast.
withdrawal.confirmedeventrequiredFired when a withdrawal is confirmed on-chain.
withdrawal.failedeventrequiredFired when a withdrawal broadcast fails. Amount is refunded to balance.
conversion.confirmedeventrequiredFired when a wallet-to-wallet conversion completes successfully.
conversion.failedeventrequiredFired when a conversion fails. Source funds are returned to the originating wallet.
subscription.createdeventrequiredFired 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.renewedeventrequiredFired when a subscription successfully charges for the next billing cycle.
subscription.renewal_initiatedeventrequiredFired when a subscription begins its renewal cycle (before charge succeeds).
subscription.failedeventrequiredFired when a subscription renewal charge fails.
subscription.cancelledeventrequiredFired when a subscription is cancelled (by merchant or customer).
subscription.pausedeventrequiredFired when a subscription is paused.
subscription.resumedeventrequiredFired when a paused subscription is resumed.
subscription.updatedeventrequiredFired when subscription details (amount, interval) are updated.
refund.createdeventrequiredFired when a refund record is created (pending approval).
refund.approvedeventrequiredFired when a refund is approved by ops.
refund.rejectedeventrequiredFired when a refund is rejected.
refund.completedeventrequiredFired when a refund is fully disbursed back to the customer.
refund.failedeventrequiredFired when a refund disbursement fails.
dispute.openedeventrequiredFired when a dispute is opened against a transaction.
dispute.resolvedeventrequiredFired when a dispute is resolved.
dispute.rejectedeventrequiredFired when a dispute is rejected.
dispute.escalatedeventrequiredFired when a dispute is escalated to a higher tier.
aml.flaggedeventrequiredFired when a payout is blocked by AML screening. Subscribe to react to compliance holds.
splitpayment.createdeventrequiredMarketplace / 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.approvedeventrequireddocs.eventKybApprovedDesc
kyb.rejectedeventrequireddocs.eventKybRejectedDesc
kyb.thread.createdeventrequiredA new compliance thread was created on a KYB review.
kyb.thread.repliedeventrequiredA merchant or admin replied to a KYB compliance thread (author field distinguishes).
kyb.thread.resolvedeventrequiredAn admin resolved a KYB compliance thread.
kyb.thread.dismissedeventrequiredAn admin dismissed a KYB compliance thread.
agreement.resign_requestedeventrequiredMerchant received re-sign request for MSA v3. Action required within 30 days.
agreement.resignedeventrequiredMerchant 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.

Payin events
json
{
  "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"
}
Payout events
json
{
  "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"
}
Withdrawal events
json
{
  "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.

POST/v1/webhooks/test
ParameterTypeRequiredDescription
endpointIdstringrequiredThe ID of the webhook endpoint to send the test event to. Get this from GET /webhooks/endpoints.
eventTypestringrequiredThe event type to simulate (e.g. payin.confirmed, payout.confirmed). Must be a valid event name.
bash
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"
  }'
json
{
  "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.created

Fired immediately when a new payin is created and a TRON deposit address is assigned.

json
{
  "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.confirmed

Fired when a payin reaches 5 on-chain confirmations. Merchant balance is credited at this point.

json
{
  "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.expired

Fired when a payin address expires without receiving the expected USDT amount.

json
{
  "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.failed

Fired 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.

json
{
  "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.underpaid

Fired when the customer sends less USDT than the requested amount. The shortfall field shows how much is missing.

json
{
  "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.overpaid

Fired when the customer sends more USDT than the requested amount. The surplus field shows the excess.

json
{
  "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.confirmed

Fired when an outbound payout is confirmed on-chain. The txHash is the on-chain transaction ID.

json
{
  "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.failed

Fired when a payout broadcast fails. The amount is automatically refunded to your merchant balance.

json
{
  "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.approved

Fired when a withdrawal request passes admin review and is queued for on-chain broadcast.

json
{
  "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.confirmed

Fired when a withdrawal is confirmed on-chain. netAmount is what your wallet actually received after fees.

json
{
  "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.failed

Fired when a withdrawal broadcast fails. The amount is automatically refunded to your balance.

json
{
  "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"
}

SDKs & Plugins

Node.js SDK

The official TypeScript SDK with full type safety and built-in webhook verification.

bash
npm install @zyndpay/sdk
javascript
const { 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.

typescript
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 → CONFIRMED
Tip
Webhook handlers must return a 2xx status within 10 seconds. Failed deliveries are retried with exponential backoff up to 5 times.

New 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.

typescript
// 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}`);
typescript
// 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)
typescript
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);
typescript
// 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();
typescript
const balances = await zyndpay.balances.getAll();
// { USDT_TRC20: '245.50', XOF: '150000', USD: '0' }
typescript
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}`);
Tip
For bulk payments, use zyndpay.bulkPayments — see the Bulk Payments section for the full workflow.

Python SDK

The official Python SDK built on <ic>requests</ic> with a simple, synchronous interface.

bash
pip install zyndpay
python
from 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.

python
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 → CONFIRMED
Tip
Webhook handlers must return a 2xx status within 10 seconds. Failed deliveries are retried with exponential backoff up to 5 times.

New 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().

python
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+.

bash
composer require zyndpay/zyndpay-php
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
<?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 → CONFIRMED
Tip
Webhook handlers must return a 2xx status within 10 seconds. Failed deliveries are retried with exponential backoff up to 5 times.
PHP Signature Verification

Raw PHP implementation without the SDK. Produces the same HMAC-SHA256 check as the Node.js and Python examples above.

php
<?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().

php
$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.

1

Download the plugin ZIP from the ZyndPay GitHub repository.

2

In WordPress admin go to Plugins → Add New → Upload Plugin.

3

Upload the ZIP and click Activate.

4

Navigate to WooCommerce → Settings → Payments → ZyndPay.

5

Enter your API key and webhook secret, then save.


Sandbox

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.

Isolated balance
Sandbox funds are completely separate from your live balance.
Identical API
Same endpoints, same response shapes, same webhook events.
Instant confirm
Simulate payment confirmation in one API call — no waiting.
Sandbox before KYB
Available to all merchants from day one, no approval needed.

Testing Guide

Follow these steps to test your full payment flow end-to-end before going live.

1
Get your sandbox API key
In your dashboard, go to API Keys and create a key with the zyp_test_sk_... prefix. Sandbox keys are free and always available. You can also use the key from the dashboard's Sandbox Playground.
2
Create a test pay-in
Call POST /v1/payments with your zyp_test_sk_... key and add either ?sandbox=true on the URL or the x-sandbox: true header. The key prefix alone does not auto-route — the sandbox flag is required on every payment-creation request. You receive a real-looking TRON address and payment URL, but no blockchain activity happens.
3
Simulate blockchain confirmation
Call POST /v1/sandbox/payments/:id/simulate. This instantly moves the transaction to CONFIRMED status, fires the payin.confirmed webhook to your endpoint, and credits your sandbox balance.
4
Verify your webhook fires
Check your server logs or use a tool like ngrok or webhook.site to expose a local endpoint. The simulate call fires a real webhook with a real signature — run your verification code against it.
5
Reset and repeat
Call POST /v1/sandbox/reset to clear all sandbox transactions and start fresh. Useful for automated test suites.
bash
# 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"
  }'
typescript
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"
bash
# 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_..."
Common gotchas
Using a live key (zyp_live_sk_...) with sandbox: true returns LIVE_KEY_SANDBOX_REQUEST. Use a test key for sandbox requests.
The simulate endpoint only works on transactions in AWAITING_PAYMENT status. If you call it twice or after expiry, you get a 400 error.
Webhook signatures in sandbox are real — the same HMAC-SHA256 algorithm applies. Your verification code must work exactly the same as in production.
Sandbox balances are not transferable to production. They exist only for testing and are wiped on reset.

Simulate Payment

POST/v1/sandbox/payments/:id/simulate

Instantly confirms a sandbox payin, fires the <ic>payin.confirmed</ic> webhook, and credits the sandbox balance — exactly like a real on-chain confirmation.

bash
curl -X POST \
  https://api.zyndpay.io/v1/sandbox/payments/{id}/simulate \
  -H "X-Api-Key: zyp_test_sk_..."
json
{
  "success": true,
  "data": {
    "message": "Payin simulation triggered",
    "transactionId": "f4b2cb0f-08ce-4408-88d4-0a678ca0aae2"
  }
}
Tip
Call POST /v1/sandbox/reset to delete all sandbox transactions and start with a clean slate. Useful for automated test suites that need a consistent starting state.

Reference

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.

json
{
  "success": false,
  "error": {
    "code": "AMOUNT_TOO_LOW",
    "message": "Minimum payin amount is 1 USDT"
  },
  "statusCode": 400
}
CodeHTTP StatusRequiredDescription
UNAUTHORIZED401requiredAPI key is missing or invalid. Check that you are sending the X-Api-Key header and the key exists in your dashboard.
FORBIDDEN403requiredThe 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_KEY401requiredThe provided API key is invalid or has been revoked.
INSUFFICIENT_SCOPE403requiredThe API key does not have permission for this action.
TOTP_REQUIRED403requiredTwo-factor authentication is required to perform this action.
TOTP_INVALID400requiredThe provided TOTP code is invalid or expired.
BACKUP_CODE_INVALID400requiredThe backup code provided is invalid.
EMAIL_UNVERIFIED403requiredAccount email address has not been verified.
ACCOUNT_CLOSED403requiredThis merchant account has been closed.
REGISTRATION_BLOCKED403requiredNew registrations are currently blocked for this account.
SANDBOX_KEY_LIVE_REQUEST400requiredYou sent a sandbox key but the resource is a live transaction. Use a zyp_live_sk_... key for production requests.
LIVE_KEY_SANDBOX_REQUEST400requiredYou sent sandbox: true with a live API key. Use a zyp_test_sk_... key for sandbox requests.
IP_ALLOWLIST_SELF_LOCKOUT400requiredThis change would lock out your own IP address.
MERCHANT_NOT_FOUND404requiredThe specified merchant was not found.
MERCHANT_STATUS_INVALID403requiredThe merchant account is not in a valid state for this action.
KYB_REQUIRED403requiredThis operation requires completed KYB (Know Your Business) verification. Complete KYB in your dashboard to unlock full limits.
RATE_LIMIT_EXCEEDED429requiredToo many requests. Check the Retry-After response header for the number of seconds to wait before retrying.
RATE_LIMITED429requiredToo many requests. Please slow down.
MERCHANT_LIMIT_EXCEEDED403requiredMerchant-level transaction limit has been exceeded.
MONTHLY_LIMIT_EXCEEDED403requiredMonthly limit reached. Resets on the first of next month.
BALANCE_CAP_REACHED403requiredWallet balance cap has been reached.
DAILY_CAP_EXCEEDED403requiredDaily transaction cap has been exceeded.
DAILY_CONVERSION_LIMIT403requiredDaily conversion volume limit reached.
DAILY_CONVERSION_COUNT_LIMIT403requiredDaily conversion count limit reached.
LIMIT_EXCEEDED_PAYLINKS403requiredMaximum number of paylinks for this plan has been reached.
LIMIT_EXCEEDED_WEBHOOKS403requiredMaximum number of webhook endpoints has been reached.
LIMIT_EXCEEDED_API_KEYS403requiredMaximum number of API keys has been reached.
LIMIT_EXCEEDED_TEAM_MEMBERS403requiredMaximum number of team members has been reached.
LIMIT_EXCEEDED_BULK_BATCH403requiredMaximum number of items in a bulk batch has been reached.
INVALID_ADDRESS400requiredThe TRON wallet address is malformed. Addresses must start with T and be 34 characters in base58 format.
ADDRESS_IN_USE409requiredThis address is already in use by another transaction.
ADDRESS_NOT_FOUND404requiredThe whitelistAddressId does not match any saved withdrawal address on your account. Add addresses in your dashboard under Withdrawal Addresses.
NO_WHITELISTED_ADDRESS400requiredNo whitelisted withdrawal address found for this merchant.
NO_PAYOUT_ADDRESS400requiredNo payout address configured for this merchant.
ADDRESS_NOT_WHITELISTED403requiredThe destination address is not on your merchant whitelist. Add it in your dashboard before sending funds to it.
ADDRESS_COOLDOWN403requiredThis address is in a cooldown period and cannot be used yet.
ADDRESS_REMOVAL_DISABLED410requiredSaved crypto recipients cannot be deleted.
WHITELIST_VALIDATION_FAILED400requiredAddress whitelist validation failed.
INVALID_CONTEXT400requiredInvalid context provided for this operation.
WALLET_NOT_FOUND404requiredThe specified wallet was not found.
WALLET_DIRECT_WITHDRAW_DISABLED403requiredDirect withdrawal from this wallet type is disabled.
INSUFFICIENT_BALANCE400requiredYour USDT balance is too low for the requested payout or withdrawal amount. Check your balance at GET /v1/wallets/balance.
AMOUNT_TOO_SMALL400requiredAmount is below the minimum for this operation (5 USDT for payins, 5 USDT for payouts/withdrawals).
AMOUNT_TOO_LARGE400requiredAmount exceeds the maximum allowed for this operation or your current compliance tier.
INVALID_AMOUNT400requiredThe amount provided is not a valid number.
INVALID_TRANSACTION_TYPE400requiredThe transaction type specified is not valid.
INVALID_TRANSACTION_STATUS400requiredThe operation is not allowed in the current transaction status (e.g. trying to cancel a confirmed payout).
REFUND_WINDOW_EXPIRED400requiredThe refund window for this transaction has expired.
REFUND_EXCEEDS_AMOUNT400requiredRefund amount exceeds the original transaction amount.
REASON_NOTE_REQUIRED400requiredA reason note is required for this action.
CANNOT_CANCEL400requiredThis resource can no longer be cancelled (it has already been approved, broadcast, or completed).
DUPLICATE_EXTERNAL_REF409requiredA transaction with this externalRef already exists for your account. Use a unique reference per request, or omit it.
MISSING_IDEMPOTENCY_KEY400requiredThis endpoint requires an Idempotency-Key header. Pass a unique UUID per request.
IDEMPOTENCY_KEY_INVALID400requiredThe Idempotency-Key header is malformed.
IDEMPOTENCY_KEY_MISMATCH409requiredAn existing request with the same Idempotency-Key has different parameters. Use a new key for a different request.
CONFIG_MISSING500requiredRequired system configuration is missing.
AML_BLOCKED403requiredThis transaction was flagged by our AML screening engine and cannot be processed. Contact [email protected].
AML_SCREENING_UNAVAILABLE503requiredAML screening service is temporarily unavailable.
COMPLIANCE_LIMIT_REACHED403requiredYour monthly transaction volume has reached the compliance limit for your current tier. Complete KYB to increase your limits.
CONVERSION_NOT_ALLOWED403requiredConversion between these currencies is not permitted.
RATE_LOCK_EXPIRED400requiredThe FX rate lock has expired. Request a new rate.
RATE_LOCK_NOT_FOUND404requiredThe specified rate lock was not found.
RATE_LOCK_ALREADY_USED409requiredThis rate lock has already been used.
RATE_UNAVAILABLE503requiredExchange rate is currently unavailable. Try again shortly.
RATE_STALE400requiredThe exchange rate is stale. Refresh and try again.
FX_UNAVAILABLE503requiredFX conversion service is temporarily unavailable.
INVALID_CONVERSION_PAIR400requiredThe specified currency conversion pair is not supported.
NEGATIVE_REVENUE400requiredThis conversion would result in negative revenue.
MISSING_PHONE400requiredCustomer phone number is required for this payment method.
MISSING_OPERATOR400requiredMobile money operator code is required.
MISSING_BANK_DETAILS400requiredBank account details are required.
MISSING_MOBILE_MONEY_FIELDS400requiredRequired mobile money fields are missing.
MISSING_BANK_FIELDS400requiredRequired bank transfer fields are missing.
FIAT_DESTINATION_REQUIRED400requiredA fiat destination must be configured before withdrawing.
FIAT_DESTINATION_NOT_FOUND404requiredThe specified fiat destination was not found.
FIAT_DESTINATION_INVALID400requiredThe fiat destination configuration is invalid.
REFUND_RAIL_NOT_AVAILABLE400requiredRefund is not available via the original payment rail.
WITHDRAWAL_NOT_FIAT400requiredThis operation requires a fiat withdrawal.
WITHDRAWAL_APPROVE_NOT_SUPPORTED_FOR_FIAT400requiredFiat 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_REVIEW400requiredThe withdrawal is not in PENDING_REVIEW status.
BENEFICIARY_REQUIRED400requiredA beneficiary must be specified for this payout.
BENEFICIARY_INVALID400requiredThe beneficiary details are invalid.
BENEFICIARY_NOT_FOUND404requiredThe specified beneficiary was not found.
BENEFICIARY_ALREADY_EXISTS409requiredA beneficiary with these details already exists.
BENEFICIARY_IN_USE409requiredThis beneficiary is in use by an active transaction.
BENEFICIARY_REJECTED403requiredThis beneficiary has been rejected by compliance.
BENEFICIARY_COOLDOWN403requiredThis beneficiary is in a cooldown period.
BENEFICIARY_NOT_VERIFIED403requiredThis beneficiary has not been verified yet.
SELF_OWNED_REQUIRED400requiredA self-owned beneficiary is required for this operation.
MAX_BENEFICIARIES_REACHED403requiredMaximum number of beneficiaries has been reached.
THIRD_PARTY_NOT_ALLOWED403requiredThird-party beneficiaries are not allowed for this operation.
CARD_PAYMENTS_DISABLED403requiredCard payments are not enabled for this merchant.
MOBILE_MONEY_PAYMENTS_DISABLED403requiredMobile money payments are not enabled for this merchant.
USDT_PAYMENTS_DISABLED403requiredUSDT payments are not enabled for this merchant.
NO_METHODS_ENABLED403requiredNo payment methods are enabled for this paylink.
FEE_NOT_CONFIGURED500requiredFee configuration is missing for this payment method.
PAYMENT_METHOD_NOT_ACCEPTED400requiredThe specified payment method is not accepted for this transaction.
PROVIDER_INITIATE_FAILED502requiredThe payment provider failed to initiate the transaction.
PROVIDER_MISSING_URL502requiredThe payment provider did not return a redirect URL.
OPERATOR_NOT_SUPPORTED400requiredThe mobile money operator is not supported in this region.
INVALID_STATE400requiredThe transaction is not in the correct state for this action.
OTP_INVALID400requiredThe OTP code provided is incorrect.
OTP_EXPIRED400requiredThe OTP code has expired. Request a new one.
OTP_NOT_APPLICABLE400requiredOTP submission is not applicable for this transaction.
MOMO_PROVIDER_ERROR502requiredThe mobile money provider returned an error.
CUSTOMER_PHONE_REQUIRED400requiredCustomer phone number is required for mobile money payments.
CANNOT_CHANGE_CURRENCY_AFTER_PRODUCTS400requiredCurrency cannot be changed after products have been added.
PAYLINK_EMPTY400requiredThe paylink has no products configured.
MARKETPLACE_DISABLED403requiredMarketplace / Connect features are not enabled for this account.
NOT_A_PLATFORM_MERCHANT403requiredThis merchant is not a platform merchant.
SPLIT_RULE_NOT_FOUND404requiredThe specified split rule was not found.
SPLIT_RULE_IN_USE409requiredThis split rule is in use and cannot be deleted.
SPLIT_RULE_INVALID_BPS_SUM400requiredSplit rule basis points do not sum to 10000.
SPLIT_RULE_MISSING_ZYNDPAY_RECIPIENT400requiredSplit rule is missing the ZyndPay fee recipient.
SPLIT_RULE_MISSING_PLATFORM_RECIPIENT400requiredSplit rule is missing the platform merchant recipient.
SPLIT_RULE_MISSING_SUB_MERCHANT_RECIPIENT400requiredSplit rule is missing the sub-merchant recipient.
SPLIT_RULE_INVALID_ZYNDPAY_BPS400requiredZyndPay fee basis points in this split rule are below minimum.
SUB_MERCHANT_NOT_CONNECTED403requiredThis sub-merchant is not connected to your platform.
SUB_MERCHANT_ALREADY_CONNECTED409requiredThis sub-merchant is already connected to a platform.
SUB_MERCHANT_KYB_REQUIRED403requiredThe sub-merchant must complete KYB before this action.
SUB_MERCHANT_SUSPENDED403requiredThe sub-merchant account is suspended.
SUB_MERCHANT_AML_FLAGGED403requiredThe sub-merchant has been flagged by AML screening.
SUB_MERCHANT_INVITATION_NOT_FOUND404requiredThe sub-merchant invitation was not found.
SUB_MERCHANT_INVITATION_EXPIRED400requiredThe sub-merchant invitation has expired.
SUB_MERCHANT_INVITATION_ALREADY_USED409requiredThis invitation has already been accepted.
SUB_MERCHANT_HAS_PENDING_BALANCE409requiredThe sub-merchant has a pending balance that must be settled first.
SPLIT_PAYMENT_NOT_FOUND404requiredThe specified split payment was not found.
SPLIT_PAYMENT_ALREADY_REVERSED409requiredThis split payment has already been reversed.
IMPORT_VALIDATION_FAILED400requiredOne or more items in the bulk import failed validation.
VALIDATION_ERROR400requiredRequest body failed validation. The details field lists each invalid field and reason.
NOT_FOUND404requiredThe requested resource does not exist or does not belong to your merchant account.
DUPLICATE_RESOURCE409requiredA resource with this unique value already exists (e.g. duplicate email, slug, or field).
CONFLICT409requiredA conflict occurred with an existing resource.
BAD_REQUEST400requiredThe request was malformed or contained invalid parameters.
INVALID_WEBHOOK_SIGNATURE400requiredWebhook signature verification failed. Check your webhook secret and that you are using the raw request body.
INTERNAL_ERROR500requiredUnexpected server error. Include the requestId from the response when contacting [email protected].
SERVICE_UNAVAILABLE503requiredThe 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.

EndpointRate LimitRequiredDescription
Default100/60srequiredGlobal rate limit per API key.
POST /payments30/60srequiredPayin creation (POST /payments) per merchant.
POST /payout10/60srequiredPayout creation (POST /payout) per merchant.
POST /withdrawals5/60srequiredWithdrawal requests per merchant.
Tip
Enterprise merchants can request higher rate limits — contact <ic>[email protected]</ic>.
API Documentation — ZyndPay