Crypto payments, end to end
Every call from “we have a new merchant” to “the buyer paid”, in order.
The first three steps are identical to Card payments — same customer, same agreements, same KYB loop. This page covers them briefly and then diverges.
The shape of it
1 create the customer ← once per merchant
2 accept the agreements ← once per merchant
3 run the KYB loop ← once per merchant, and see below
4 (optional) provision wallets ← once per merchant
5 create an order ← per purchase
6 follow it to paid ← per purchase
Crypto does not wait for KYB
The important difference. payments.crypto is granted at customer creation,
alongside wallets, payouts and refunds. Only payments.fiat_card and
withdrawals are KYB-gated.
So a merchant can take crypto immediately — steps 1, 2 and 5, and you are live. Step 3 still matters if they will ever take cards or withdraw, and it is worth starting early, but it does not block this rail.
{ "capability": "payments.crypto", "enabled": true, "available": true }
{ "capability": "payments.fiat_card", "enabled": false, "available": false,
"reason": "kyb_required" }
That is a brand-new customer, before any verification.
0. Get a token
curl -X POST https://www.niftipay.com/api/oauth/token \
-H "Content-Type: application/json" \
-d '{ "grant_type": "client_credentials",
"client_id": "<partner_client_id>",
"client_secret": "<partner_client_secret>",
"scope": "customers:write orders:create orders:read wallets:read wallets:write" }'
Token is at data.access_token. Scope requests are all-or-nothing.
1–3. Customer, agreements, KYB
Identical to the card rail — see Card payments, steps 1–3. In short:
POST $BASE/customers → partner_customer_id
GET $BASE/customers/pc_…/agreements → two required
POST $BASE/customers/pc_…/agreements/{id}/acceptances → accept each
POST $BASE/customers/pc_…/verifications → the KYB loop
You can start selling crypto after step 1 and 2. KYB can run in parallel.
4. Wallets (optional)
Every customer is provisioned with deposit wallets. You rarely need to touch
them — POST /orders mints a per-order address by itself — but you can read or
create them:
curl $BASE/customers/pc_…/wallets -H "Authorization: Bearer $TOKEN"
{
"data": {
"wallets": [
{
"chain": "BTC",
"address": "bc1q…",
"balance": "0",
"network": "mainnet"
},
{
"chain": "ETH",
"address": "0x…",
"balance": "0",
"tokens": {
"USDT": "0",
"USDC": "0"
},
"network": "mainnet"
}
]
}
}POST /customers/{id}/wallets provisions any chain that is missing and returns
the full set. It is safe to call repeatedly.
5. Create the order
curl -X POST $BASE/customers/pc_…/orders \
-H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "reference": "CRY-1042", "network": "ETH", "asset": "USDT",
"amount": 100, "currency": "EUR",
"firstName": "Ada", "lastName": "Lovelace", "email": "ada@example.com" }'
{
"data": {
"order": {
"id": "7d1dcdd4-…",
"reference": "CRY-1042",
"network": "ETH",
"asset": "USDT",
"amount": "116.751879",
"totalToSend": "116.751879",
"currency": "EUR",
"fiatAmount": "100",
"address": "0x49d83b0d…",
"paymentUri": "ethereum:0x49d83b0d…?contract=0xdAC17F…&amount=116.751879",
"qrUrl": "https://www.niftipay.com/api/qr?data=…",
"status": "pending",
"expiresAt": "2026-08-27T21:12:02.923Z"
},
"replayed": false
}
}Reading that response
You price in fiat; the buyer pays in crypto. You send amount: 100 with
currency: "EUR"; we return amount as the token amount at the current
rate. fiatAmount echoes what you asked for.
Show totalToSend, not amount. They are equal when there are no network
fees and differ when there are. totalToSend is what actually has to arrive.
Three ways to present it, all equivalent — address for copy-paste,
paymentUri for a wallet deep link, qrUrl for a scannable code.
expiresAt is a real deadline — typically twelve hours. The rate is held
until then. After it, create a new order.
Duplicate reference behaves differently here
The card rail refuses a duplicate reference with 409 reference_conflict.
This rail replays it: you get 201 with the original order and
"replayed": true.
That is deliberate — a crypto order holds a rate and an address, and minting a
second one for the same purchase would split the payment across two addresses.
Check replayed if you care whether you just created something.
Idempotency-Key is still required, and still the right thing to reuse on a
retry.
6. Follow it
Listen for the payment.* events (payment.confirmed, payment.expired, payment.refunded, … — see Webhooks), or poll:
curl $BASE/orders/7d1dcdd4-… -H "Authorization: Bearer $TOKEN"
Note the shape difference from the card rail: a crypto order is read by its
uuid at /orders/{orderId}, while a fiat order is read by its short integer
order_key at /fiat-orders/{orderKey}.
To reconcile rather than follow one order, page the customer’s orders newest
first — List crypto orders. It is the only
partner list endpoint with a real cursor: page on next_cursor until it comes
back null.
status runs pending → confirmed, or expired. An underpayment is its own
state rather than a silent failure.
What goes wrong, and what it means
| Response | Cause | Do |
|---|---|---|
403 capability_not_enabled |
payments.crypto off for this customer |
Rare — it is granted at creation. Check capabilities. |
400 invalid_request |
Bad network/asset pair, or a missing field |
The message names which. network is the chain, asset is the token. |
400 idempotency_key_required |
No key on the POST | Always send one. |
409 idempotency_key_reuse |
Same key, different body | Use a new key for a genuinely different order. |
503 temporarily_unavailable |
Transient | The only code that means retry. |
Card or crypto: how they differ
| Card | Crypto | |
|---|---|---|
| Capability | payments.fiat_card — KYB-gated |
payments.crypto — granted at creation |
| Scope | payments:create |
orders:create |
| Needs an integration | Yes | No |
| Endpoint | POST /customers/{id}/fiat-orders |
POST /customers/{id}/orders |
| Read by | order_key (integer) at /fiat-orders/{key} |
id (uuid) at /orders/{id} |
| Amount | amount_cents, minor units |
amount, major units, fiat-priced |
| Buyer pays via | pay_url — a hosted page |
An address, URI or QR |
Duplicate reference |
409 reference_conflict |
Replayed, 201 with the original |
| Expiry | Set by the payment link | expiresAt, ~12 hours, holds the rate |
Both rails share the customer, the agreements, the KYB case, the webhooks and the error envelope. If you are integrating both, do steps 1–3 once.