PaymonetraDocs

Crypto payments

Take USDT, settle in naira. One call, and the payer goes to a page we host.

#Start a USDT payment

POSThttps://api.paymonetra.com/v1/payments/cryptoSecret key

Takes a payment in USDT and settles it to the merchant in naira. A separate endpoint from POST /payments, not a flag on it, because the two name different things: that one names a naira price, this one names a coin amount.

The naira is not known when you call this. our exchange partner do not lock a rate: the trade happens when the payer's coin arrives, at the market rate then. So the merchant names the coin, and every naira figure we return before settlement is labelled an estimate and must never be shown as a price.

HTTP
POST /payments/crypto
Authorization: Bearer sk_live_...

{
  "coin_amount": 5,
  "network": "trc20",
  "reference": "order-1043",
  "customer_name": "Ada Obi",
  "customer_email": "ada@example.com",
  "description": "One year of hosting",
  "redirect_url": "https://yourshop.com/thanks"
}
Field
coin_amountRequired. USDT, not naira. Minimum 2, maximum 7,000.
coinusdt only for now. Anything else is refused.
networktrc20 (default) or bep20. Anything else is refused.
referenceYour order id. Idempotent: the same one returns the same payment.
customer_name, customer_emailOptional, and worth sending: the payer is not asked again.
redirect_urlOptional, https only.
JSON
{
  "reference": "12ed91f4107f0c6e35",
  "status": "awaiting_details",
  "rail": "usdt",
  "coin": "USDT",
  "coin_network": "TRC20",
  "coin_amount": "5.000000",
  "coin_address": null,
  "amount_estimate": 0,
  "collects": ["usdt_trc20"],
  "checkout_url": "https://pay.paymonetra.com/12ed91f4107f0c6e35"
}

coin_address is null here and that is correct on the convert rail. Our exchange partner assigns an address for 30 minutes, so it is minted when the payer opens the page, not when you create the payment. A link you send by email and they open an hour later would otherwise show a dead address. Send them to checkout_url.

On the hold rail it is returned immediately. A business holding USDT collects at its own permanent address, so nothing is minted and nothing expires, and coin_address is filled in on this response.

#Which rail a payment is on

POST /payments/crypto is not refused when a business holds rather than converts. It settles whichever way their setting says, so the same call returns a materially different thing depending on a toggle elsewhere. Every reply says which:

settles_asYou get
convertingnairaamount_estimate, rate_estimate, coin_address null until minted
holdingcoincoin_requested, coin_received, coin_address filled at once, and no naira figure at all

Read settles_as rather than inferring from coin_address being null, which is what you had to do before and is a deduction rather than a read.

It is frozen onto the payment when it is created, so a business flipping the setting cannot change what an open payment was promised.

#Getting the address without redirecting

Send "show_address": true and it is minted in the same call and returned. customer_name and customer_email become required, because the address is created there and then and a receipt has to go somewhere; on the redirect flow our own page collects them instead.

Only do this when you are about to show it. The address lives 30 minutes from that moment, so creating payments in a nightly batch with show_address hands every payer a dead one.

If we cannot reach our exchange partner, the payment is still created and the response carries the reason rather than failing:

JSON
{ "reference": "12ed91f4107f0c6e35",
  "coin_address": null,
  "address_error": "We are still setting up your payment. Refresh this page in a moment.",
  "address_retry": "POST /payments/12ed91f4107f0c6e35/address" }

Failing outright would lose the reference, and a merchant retrying would open a second payment for one order.

HTTP
POST /payments/{reference}/address

The same thing on its own, for a payment created earlier. Two jobs the flag cannot do: create at order time and show after a confirmation step, without burning the window in between; and retry without opening a second payment. Idempotent, so calling it twice returns the same address.

Amounts come back as strings. "5.000000", not 5.0. A float renders 28.45 as 28.449999999999999 and this is a figure your payer has to match exactly.

#What the payer sees

The coin amount, a QR, the address, the network named in full, a countdown, and the naira estimate marked as an estimate. If you sent customer_name and customer_email they are not asked for anything: the address appears on its own.

One warning is not optional. The page says "Send only USDT on TRON (TRC20). Coin sent on another network cannot be recovered." If you build your own page instead of redirecting, you must show it. Coin sent on the wrong chain is gone, and neither we nor our exchange partner can return it.

#Settlement

It is fast. A real payment on 19 Sep 2026 took 33 seconds from the deposit landing to naira in the account.

You are credited the naira the trade actually produced, never the estimate. collection.success fires the same as on the bank rail, with the coin detail added:

JSON
{
  "reference": "12ed91f4107f0c6e35-452cdf",
  "amount": 6826.70,
  "fee": 102.40,
  "net": 6724.30,
  "currency": "NGN",
  "rail": "usdt",
  "coin_amount": "5.000000",
  "coin_received": "5.000000",
  "coin_txid": "9e97...936e",
  "rate": 1365.34,
  "checkout_reference": "12ed91f4107f0c6e35",
  "status": "paid"
}

coin_received against coin_amount is what decides paid, underpaid or overpaid. The naira never decides it, because the naira is an outcome of the trade rather than a figure anyone agreed to.

#The webhook a held deposit fires

collection.success, on every deposit to the address, attributed or not, the same rule as a transfer into a permanent bank account. Same keys as the convert-rail payload so one handler reads both; the difference is the units, and currency says which:

Example
{ "event": "collection.success", "mode": "live",
  "data": {
    "reference":     "0x0bf8…2e84",        // the chain txid
    "amount":        "1.000000",           // coin, gross
    "fee":           "0.015000",           // coin, our 1.5%
    "net":           "0.985000",           // coin, what they hold
    "currency":      "USDT",               // not NGN: there is no naira here
    "rail":          "usdt",
    "settles_as":    "coin",
    "coin":          "USDT", "coin_network": "BEP20",
    "coin_amount":   "1.000000", "coin_received": "1.000000",
    "coin_txid":     "0x0bf8…2e84",
    "checkout_reference": null,            // set when it matched a payment
    "customer_reference": "", "customer_name": "",
    "status": "paid", "paid_at": "2026-09-22T15:23:27+01:00" } }

checkout_reference is null on a direct deposit and carries the payment's reference when the amount matched one. currency is the thing to branch on: a handler that assumes amount is naira will credit a customer ₦1 for a 1 USDT payment.

This is a correction. Until 22 Sep the hold rail fired nothing at all, on any deposit, while the convert rail did. The first live hold deposit credited the merchant and told their server nothing. Found by the owner watching the test.

There is no per-payment override. If you want both behaviours on one account, say so and we will add one rather than have you flip a setting between calls, which races.

#Limits worth knowing before you build

  • 2 USDT minimum, 7,000 maximum. Both are our exchange partner's and we read them live.
  • 7,000 USDT a day is shared across every merchant, on the convert rail only. Converting to naira opens a transaction with our exchange partner under one identity, so the daily ceiling is one bucket for the whole platform rather than per merchant. Hitting it returns amount_over_limit and the message points at bank transfer. We are working on raising it.
  • The hold rail has no such ceiling, because nothing is converted: the coin arrives at the business's own address and stays there.
  • TRC20 and BEP20 only. Their API accepts more chains; we offer the two where Nigerian USDT volume actually is.
  • No test mode yet. A USDT address belongs to our exchange partner and we will not invent a fake one for a sandbox, because a developer would send real coin to it. Since 19 Oct 2026 POST gateway/crypto { collect_crypto_as: "hold" } (and anything that opens a business USDT address) in test mode answers 403 live_only. Addresses opened in test before then were moved to live.

#Which rails you can use

The merchant object carries collects:

JSON
{ "collects": ["bank"] }
{ "collects": ["bank", "usdt"] }

Show the USDT option only where it contains usdt. It is there so a screen knows what to draw; the API refuses the rail regardless of what a page shows.