REST · JSON · v1

API documentation

Accept crypto payments on your site or in your app. Create an invoice, send the customer to a hosted payment page and get a signed webhook when they pay.

Get an API key
Base URLhttps://zypher.exchange/api

How it works

All requests and responses are JSON over HTTPS. Amounts are decimal strings and timestamps are ISO 8601 in UTC.

  1. 1

    Create a store

    Open Merchant, create a store and copy its API key and secret.

  2. 2

    Create an invoice

    Call POST /v1/invoice and send the customer to the returned paymentUrl.

  3. 3

    Get paid

    We call your webhook with invoice.paid. Check the signature and fulfil the order.

Authentication

Every request carries your store's API key and an HMAC-SHA256 signature made with its secret.

  • X-API-Key

    Your store's API key.

  • X-API-Sign

    Hex HMAC-SHA256 of the request body, keyed with your API secret. For GET requests, sign an empty string.

  • X-API-Timestamp

    Recommended. Unix time in seconds. With it, sign `timestamp.METHOD.path.body` instead of the body: the request is valid for 5 minutes and only once, so a captured request cannot be replayed.

Sign the compact JSON exactly as JSON.stringify produces it: no extra spaces, keys in the order you send them.

Signature
sign = hex(HMAC_SHA256(api_secret, body))   // body = '' for GET
Signature with timestamp (recommended)
ts   = unix_seconds()
sign = hex(HMAC_SHA256(api_secret, ts + "." + METHOD + "." + "/api/v1/invoice?..." + "." + body))
// headers: X-API-Key, X-API-Sign: sign, X-API-Timestamp: ts
Requests without X-API-Timestamp keep working with the body-only signature. The raw body bytes you send are what is checked, so any JSON formatting is fine.
The secret is shown once, when the store is created or the key is regenerated. Keep it on your server and never ship it in a browser or mobile app.
If the store has an IP allowlist, requests from other addresses get 403.

Try it with your key

Add your store's API key and secret to send real requests from this page.

Stored only in this browser. The secret never leaves it: it signs requests locally, and requests go straight to the API. Requests sent from here are real and create real invoices in your store.

Create invoice

Price an invoice in a coin, to get a deposit address at once, or in fiat and let the customer pick the coin on the payment page.

POST/v1/invoice

Body parameters

  • currencystringrequired

    Coin, for example USDT. Required unless fiatAmount is set.

  • networkstringrequired

    Network, for example tron. Required unless fiatAmount is set.

  • amountstringrequired

    Amount in that coin, as a decimal string. Required unless fiatAmount is set.

  • fiatAmountstringoptional

    Amount in fiat, up to 1,000,000. The customer chooses the coin on the payment page.

  • fiatCurrencystringoptional

    Fiat currency code. Defaults to USD.

  • orderIdstringoptional

    Your own order reference. It comes back in responses and webhooks.

  • metadataobjectoptional

    Any JSON object. Stored with the invoice and returned when you read it.

  • acceptedCurrenciesstring[]optional

    Fiat invoices only: the coins the buyer may pay with, e.g. ["USDT", "BTC"]. Default: every supported coin.

  • expiresInintegeroptional

    How long the invoice stays open, in seconds (300 to 604800). Default: 3600 for fiat, 1800 for crypto invoices.

Example request

BODY='{"currency":"USDT","network":"tron","amount":"100.00","orderId":"order-123"}'
SIGN=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$API_SECRET" | awk '{print $NF}')

curl -X POST https://zypher.exchange/api/v1/invoice \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Sign: $SIGN" \
  -H "Content-Type: application/json" \
  -d "$BODY"
Response
{
  "id": "5f0c3a52-7b1e-4f6a-9d2e-0c1b7a9e4d11",
  "storeId": "b2d8e0f4-33a1-4c57-8e6b-1f9a2c7d5e30",
  "orderId": "order-123",
  "currency": "USDT",
  "network": "tron",
  "amount": "100.00",
  "fiatAmount": null,
  "fiatCurrency": null,
  "paidAmount": null,
  "depositAddress": "TXk3...Qp9",
  "status": "PENDING",
  "expiresAt": "2026-01-01T00:30:00.000Z",
  "paidAt": null,
  "txHash": null,
  "metadata": { "customer": "[email protected]" },
  "createdAt": "2026-01-01T00:00:00.000Z",
  "paymentUrl": "/pay/5f0c3a52-7b1e-4f6a-9d2e-0c1b7a9e4d11"
}

Returns the invoice. Send the customer to paymentUrl on our site, for example https://zypher.exchange/pay/{id}.

Try it
Add your API key and secret above first.

Get invoice

Returns the invoice with its current status. Rely on webhooks for changes and use this to double-check before you ship.

GET/v1/invoice/{id}
BODY=''
SIGN=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$API_SECRET" | awk '{print $NF}')

curl https://zypher.exchange/api/v1/invoice/5f0c3a52-7b1e-4f6a-9d2e-0c1b7a9e4d11 \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Sign: $SIGN"
Try it
Add your API key and secret above first.

Invoice object

Fields that do not apply yet are null.

  • idstring

    Invoice id (UUID).

  • storeIdstring

    The store that owns the invoice.

  • orderIdstring | null

    Your order reference.

  • currencystring | null

    Coin. null on a fiat invoice until the customer picks one.

  • networkstring | null

    Network. null on a fiat invoice until the customer picks one.

  • amountstring | null

    Amount due, in the coin.

  • fiatAmountstring | null

    Amount due in fiat, on fiat invoices.

  • fiatCurrencystring | null

    Fiat currency code.

  • paidAmountstring | null

    Amount received so far, in the coin.

  • depositAddressstring | null

    Address the customer pays to.

  • statusstring

    Current status, see Statuses.

  • expiresAtstring

    When the invoice stops accepting payment.

  • paidAtstring | null

    When the payment was confirmed.

  • txHashstring | null

    Transaction hash of the payment.

  • metadataobject | null

    The object you sent, if any.

  • createdAtstring

    When the invoice was created.

  • paymentUrlstring

    Path of the hosted payment page for this invoice.

Statuses

A payment within 2% below the amount due counts as paid, and more than 2% above is overpaid.

  • PENDING

    Waiting for payment.

  • CONFIRMING

    Payment seen on the network, waiting for confirmations.

  • PAID

    Paid and confirmed.

  • OVERPAID

    Confirmed. The customer sent more than required.

  • UNDERPAID

    Part of the amount arrived. The invoice waits for the rest until it expires.

  • EXPIRED

    Expired without full payment.

Webhooks

Set a webhook URL in your store settings. We send a POST request to it when an invoice is paid.

Headers

  • X-Webhook-Signature

    Hex HMAC-SHA256 of the raw request body, keyed with your API secret.

  • X-Webhook-Event

    Event name.

  • Content-Type

    application/json

Events

  • invoice.paid

    The invoice was paid. data.status is PAID or OVERPAID.

  • test

    Sent by the test button in your store settings.

Payload

POST https://your-site.example/zypher-webhook
{
  "event": "invoice.paid",
  "data": {
    "invoiceId": "5f0c3a52-7b1e-4f6a-9d2e-0c1b7a9e4d11",
    "orderId": "order-123",
    "status": "PAID",
    "currency": "USDT",
    "network": "tron",
    "amount": "100.00",
    "paidAmount": "100.00",
    "txHash": "0x...",
    "paidAt": "2026-01-01T00:15:00.000Z"
  },
  "timestamp": 1767226500000
}

Verify the signature

Compute the HMAC over the raw body before you parse it, and compare in constant time. Reject the request if it does not match.

import { createHmac, timingSafeEqual } from 'node:crypto';

// rawBody: the request body as received (Buffer or string), before JSON.parse
export function verifyWebhook(rawBody, signature, apiSecret) {
  const expected = createHmac('sha256', apiSecret).update(rawBody).digest('hex');
  const a = Buffer.from(signature ?? '');
  const b = Buffer.from(expected);
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express: app.post('/zypher', express.raw({ type: 'application/json' }), (req, res) => {
//   if (!verifyWebhook(req.body, req.get('X-Webhook-Signature'), process.env.API_SECRET)) return res.sendStatus(401);
//   const { event, data } = JSON.parse(req.body);
//   res.sendStatus(200);
// });
When the customer pays from a Zypher balance, the payload carries paidVia: "internal_balance" and has no orderId, paidAmount, txHash or paidAt.

Delivery

  • Reply with any 2xx status within 10 seconds.
  • Failed deliveries are retried up to 5 times with growing delays, starting at 1 minute.
  • The same event can arrive more than once. Use data.invoiceId to deduplicate.
  • The URL must be public: localhost and private network addresses are not called.

Test mode (sandbox)

Build and check your integration without real money. Create test keys in the merchant dashboard: the key starts with test_. The same endpoints and the same HMAC signature work with it, made with the test secret.

  • Invoices made with a test key live apart from real ones: no real address is issued, nothing is watched on chain, nothing is credited to a balance and no commission is charged.
  • The deposit address is a placeholder that starts with TEST-. Do not send coins to it.
  • Mark a test invoice paid, overpaid, underpaid or expired with the simulate endpoint, or with the buttons on its payment page.
  • Your webhook URL receives the same invoice.paid event as for a real payment, with "test": true in data, signed with the test secret.
  • Test invoices are deleted after 30 days. A store can create up to 1000 a day.
  • Other endpoints refuse test keys with 403, so a test key can never move funds.

Simulate a payment

POST/v1/invoice/{id}/simulate

Test keys only. A live key gets 403.

Body parameters

  • statusstringrequired

    paid, overpaid, underpaid or expired. underpaid sends half of the amount and leaves the invoice open; a later paid sends the rest.

  • currencystringoptional

    For a fiat-priced invoice with no coin picked yet. USDT on Tron when left out.

  • networkstringoptional

    Network of that coin.

BODY='{"status":"paid"}'
SIGN=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$API_SECRET" | awk '{print $NF}')

curl -X POST https://zypher.exchange/api/v1/invoice/5f0c3a52-7b1e-4f6a-9d2e-0c1b7a9e4d11/simulate \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Sign: $SIGN" \
  -H "Content-Type: application/json" \
  -d "$BODY"

Payload

POST https://your-site.example/zypher-webhook
{
  "event": "invoice.paid",
  "data": {
    "invoiceId": "5f0c3a52-7b1e-4f6a-9d2e-0c1b7a9e4d11",
    "orderId": "order-123",
    "status": "PAID",
    "currency": "USDT",
    "network": "tron",
    "amount": "100.00",
    "paidAmount": "100",
    "txHash": "test_4f1c...",
    "paidAt": "2026-01-01T00:15:00.000Z",
    "test": true
  },
  "timestamp": 1767226500000
}
Try it
Add your API key and secret above first.
Going live: swap the test key and secret for the live ones. Nothing else changes.

AML address check

Check a crypto address against our risk database before you pay out to it or accept funds from it. Same authentication as invoices.

POST/v1/aml/check

Body parameters

  • addressstringrequired

    The address to check, up to 128 characters.

  • networkstringoptional

    Network, for example tron. If you leave it out, we work it out from the address format.

BODY='{"address":"TXk3...Qp9","network":"tron"}'
SIGN=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$API_SECRET" | awk '{print $NF}')

curl -X POST https://zypher.exchange/api/v1/aml/check \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "X-API-Sign: $SIGN" \
  -H "Content-Type: application/json" \
  -d "$BODY"
Response
{
  "address": "TXk3...Qp9",
  "network": "tron",
  "riskScore": 95,
  "riskLevel": "SEVERE",
  "signals": [
    { "category": "hack", "share": 95 },
    { "category": "mixer", "share": 60 }
  ],
  "sources": ["manual"],
  "checkedAt": "2026-01-01T00:00:00.000Z",
  "quota": { "used": 12, "limit": 100, "tier": "start" }
}
  • riskScorenumber

    Risk from 0 (clean) to 100 (illicit).

  • riskLevelstring

    LOW under 25, MEDIUM 25 to 49, HIGH 50 to 79, SEVERE 80 and up.

  • signalsarray

    Risk categories found with a score from 0 to 100, highest first. Categories: sanctions, ransomware, hack, darknet, scam, mixer, gambling, other, blocklist.

  • sourcesstring[]

    Where the matches come from.

  • checkedAtstring

    When the check ran.

  • quotaobject

    Checks used today, your daily limit and your merchant tier.

An empty signals list and a score of 0 mean the address is not in our risk database.
Daily limit per store by merchant tier: start 100, growth 500, business 2,000, scale 10,000, enterprise 50,000. Past it you get 429.
Try it
Add your API key and secret above first.

Currencies and networks

Pass the currency and network values exactly as listed.

  • ETH
    ethereumbasearbitrum
  • USDT
    ethereumtronbsctonsolana
  • USDC
    ethereumbasearbitrumbscsolana
  • DAI
    ethereumbasearbitrumbsc
  • TRX
    tron
  • BTC
    bitcoin
  • BNB
    bsc
  • GRAM
    ton
  • XMR
    monero
  • LTC
    litecoin
  • SOL
    solana
  • DOGE
    dogecoin

Limits and errors

Limits apply per IP address. Past a limit you get 429, so pause and retry.

  • 3 requests per second
  • 20 requests per 10 seconds
  • 100 requests per minute

Errors

Errors return JSON with statusCode, message and error.

  • 400

    Invalid parameters, or an unsupported currency and network pair.

  • 401

    Missing key or signature, unknown key or wrong signature.

  • 403

    The request IP is not on the store's allowlist.

  • 404

    Invoice not found in your store.

  • 429

    Too many requests.

Error response
{
  "statusCode": 401,
  "message": "Invalid signature",
  "error": "Unauthorized"
}