Integration Flow
This page walks through what the card section of rubin.trade does, step by step. Every call is described in the API reference; all customer calls are signed with a key you generated for an address that holds USDC.
tariffs ─► quote ─► sign MsgOrder ─► report ref ─► poll order ─► card ─► details, PIN, codes, historyIssuing a card
1. Read the tariffs
GET /v4/providers is public and returns the card providers with their tariffs, limits and on-chain settings.
const { providers } = await (await fetch(`${BASE_URL}/v4/providers`)).json();
const provider = providers[0];
const fee = provider.fees.find((f) => f.active);| Field | Use |
|---|---|
id | The {provider} path segment |
providerAvailable | false — the card provider is not responding. Do not offer new orders: an on-chain payment would wait in paid until the provider returns |
fees | Tariffs. Offer only active: true; active: false ones are kept to show the terms of cards issued earlier |
limits | Cards per address, open orders per address, top-up and withdrawal bounds |
flow | What this provider supports: issue, topup, withdraw, password, rename, … |
chain.providerId | The provider_id for MsgOrder. It can differ from id, so do not mix them |
chain.enabled | false — the chain rejects payments to this provider; do not offer issue or top-up |
chain.maxOrderUsdc | The largest single payment, null — no cap |
chain is null while the chain settings cannot be read; retry later.
2. Get a quote
const quote = await processingRequest(key, 'GET',
`/v4/${provider.id}/quote?type=issue&feeId=${fee.feeId}`);
// quote.amountUserUsdc === "39.000000"amountUserUsdc is what the customer pays, with the tariff and service fees already in it. Show it to the user before signing. The price is not locked: it is checked again when the payment arrives.
The quote checks the tariff, the card, the amount bounds and whether another order is running on the card. Check the rest yourself before signing:
| Check | If you skip it |
|---|---|
Issue: limits.maxCards against active cards plus open issue orders | The payment returns as order_rejected, minus the network fee |
limits.maxOpenOrders against GET /orders?status=open | The same |
Issue: flow.issue | The same |
chain.enabled, chain.maxOrderUsdc | The chain rejects the transaction |
providerAvailable | The order waits in paid until the card provider returns |
3. Build the payment reference
The reference ties the payment to the operation. You build it yourself:
| Operation | ref |
|---|---|
| Issue | o1:i:{feeId}:{nonce} |
| Top up | o1:t:{cardId}:{amountUsd}:{nonce} |
nonce— 16 lowercase hex characters, new for every attempt. Save therefbefore signing and reuse it when retrying the same attempt: the chain refuses a second payment with the sameref, so the user cannot pay twice.amountUsd— the amount to put on the card, up to 2 decimals, without trailing zeros:25,25.5.
import { randomBytes } from 'node:crypto';
const ref = `o1:i:${fee.feeId}:${randomBytes(8).toString('hex')}`;4. Pay on chain
The customer's key signs MsgOrder, moving amountUserUsdc from the main account to the provider's reserve. The amount is in quantums: USDC × 10⁶, as a decimal string.
import { BECH32_PREFIX, LocalWallet, Network, SubaccountInfo, ValidatorClient } from '@ritbit/v4-client-js';
import type { IndexedTx } from '@cosmjs/stargate';
import { Method } from '@cosmjs/tendermint-rpc';
const config = Network.testnet().validatorConfig;
// Mainnet: Network.mainnet().validatorConfig with config.restEndpoint = 'https://rpc.mainnet.rubin.trade'
const node = await ValidatorClient.connect(config);
const wallet = await LocalWallet.fromMnemonic(mnemonic, BECH32_PREFIX);
const subaccount = SubaccountInfo.forLocalWallet(wallet, 0);
// "39.000000" → "39000000": the API always returns 6 decimals
const amount = BigInt(quote.amountUserUsdc.replace('.', '')).toString();
const msg = node.post.composer.composeMsgProcessingOrder(
wallet.address!, provider.chain.providerId, amount, ref,
);
const tx = (await node.post.send(
subaccount, () => Promise.resolve([msg]), false, undefined, '', Method.BroadcastTxCommit,
)) as IndexedTx;
if (tx.code !== 0) throw new Error(tx.rawLog);The signer must be the same address that signs the API requests: the card belongs to whoever paid. With an EVM key, pay with EthLocalWallet.fromPrivateKey(key, BECH32_PREFIX): LocalWallet derives a different address from the same key, so the card would go to that address.
If the USDC is on a trading subaccount, withdraw the missing amount to the main account in the same transaction, placing composeMsgWithdrawFromSubaccount(address, subaccountNumber, 0, Long.fromString(quantums), address) before the MsgOrder. The transaction then passes as a whole or moves nothing. The network fee is taken before the messages run, so the main account must already hold it: it cannot come from that withdrawal.
| Error | Meaning | What to do |
|---|---|---|
ref already used for the provider | This payment went through earlier | Go to step 5 |
was submitted but was not yet found on the chain (timeout) | The payment may still land | Retry with the same ref, or go to step 5 |
insufficient funds | Not enough USDC, nothing moved | Fund the address, retry with the same ref |
amount exceeds max_order_amount of the provider, provider daily limit exceeded, processing provider is disabled | Rejected, nothing moved | Do not retry now |
5. Report the payment
Send the ref to the service. It reads the payment from the chain and creates the order from it, so repeat the call every few seconds until the payment is visible:
async function reportPayment(ref: string) {
for (let attempt = 0; attempt < 100; attempt++) {
try {
const { order } = await processingRequest(key, 'POST', `/v4/${provider.id}/orders/paid`, { ref });
return order;
} catch (e: any) {
// PAYMENT_NOT_FOUND, CHAIN_UNAVAILABLE and network errors pass with time.
if (e.status !== undefined && e.status !== 404 && e.status < 500) throw e;
await new Promise((resolve) => setTimeout(resolve, 3000));
}
}
throw new Error('Payment not visible yet, report the ref again later');
}The call is idempotent: 201 — order created, 200 — it already existed. By this step the money has moved, so an error here must not read as "payment failed" to the user.
If your app crashes before reporting, the money is not lost: the service scans the chain for such payments and creates the order itself. Repeat POST /orders/paid with the saved ref: it returns the order, finished or not.
6. Poll the order
Poll GET /v4/{provider}/orders/{orderId} every 3–5 seconds until the status is final. An issue order ends with completed, and its cardId is the new card.
| Status | Applies to | Meaning |
|---|---|---|
awaiting_payment | issue, top up | Waits for MsgOrder (order-first variant) |
accepted | withdraw | Accepted, queued for the card provider |
paid | issue, top up | Payment found on chain, queued for the card provider |
provider_pending | all | In progress at the card provider |
credit_submitted | withdraw | The card was debited, USDC is being credited to the address |
awaiting_approval | withdraw | Above the provider's automatic limits, waits for a second signature on the provider's side. No action needed; it can take longer |
refund_submitted | issue, top up | The payment is being returned to the address |
completed | all | Done |
refunded | issue, top up | The whole payment returned to the address, see failReason |
failed | all | Closed without execution, see failReason |
cancelled | all | Closed by support |
manual_review | all | Needs support — contact us with the orderId |
failReason | Meaning |
|---|---|
order_rejected | The payment arrived, but the operation is no longer allowed: tariff taken off sale, card limit reached, another order on this card. Refunded |
payment_mismatch | The amount differs from the price (it changed after the quote, or the wrong amount was sent): refunded. Order-first variant, ref paid by a different address: failed, and the money stays in the reserve until support returns it |
payment_timeout | Order-first variant: no payment before payment.expiresAt |
payment_late | Order-first variant: the payment came after the order closed. Refunded |
provider_rejected | The card provider refused. Issue, top-up: refunded. Withdrawal: failed, the card is not debited |
price_drift | The card provider's price moved beyond the tolerance. Issue, top-up: refunded. Withdrawal: failed, the card is not debited |
max_attempts | The card provider kept failing, attempts ran out. Refunded |
Using the card
Card details
POST /v4/{provider}/cards/{cardId}/reveal returns the number, CVC, PIN and expiry straight from the card provider. Nothing is cached, and the response is not stored by Rubin. Hide the details from the screen after ttlSeconds. See the warning on card data.
PIN
POST /v4/{provider}/cards/{cardId}/password with a 6-digit password sets or changes the card's PIN.
Only tariffs with canSetPassword: true support it (the same flag is on the card); others answer 422 PASSWORD_NOT_SUPPORTED. reveal returns the PIN in the pin field when the card provider exposes it. On the testnet simulator pin is a fixed 4-digit value and does not change after this call.
One-time codes
Online purchases (3-D Secure) and adding the card to a wallet such as Apple Pay ask for a one-time code. These codes reach the cardholder only through your app: without them the purchase cannot be confirmed.
While the cardholder may be paying, poll GET /v4/{provider}/verification-codes every few seconds or wait for the codes signal, and show otp with merchant, amount and currency. Only codes that have not expired are returned. Do not log them.
Balance
balanceUsd in GET /v4/{provider}/cards is a snapshot refreshed about once a minute (see balanceSyncedAt). GET /v4/{provider}/cards/{cardId}?refresh=1 asks the card provider directly. Use it when the user opens a card, not in a polling loop. balanceUsd can be null when the balance is not known.
After activeUntil the card becomes expired: top-ups, withdrawals, reveal and PIN changes answer 422 CARD_INACTIVE. Withdraw or spend the balance before that date.
Top up
The same steps as issuing, with type=topup, the cardId and amountUsd:
const quote = await processingRequest(key, 'GET',
`/v4/${provider.id}/quote?type=topup&cardId=${cardId}&amountUsd=100`);
const ref = `o1:t:${cardId}:100:${randomBytes(8).toString('hex')}`;
// pay quote.amountUserUsdc with MsgOrder, report the ref, poll the orderamountUsd is what should land on the card; amountUserUsdc includes the tariff's depositFeePercent. Within payment.driftTolerancePercent the order still completes, so the card can get slightly less: check the card balance or the deposit operation. The minimum is the larger of limits.topupMinUsd and the tariff's minDepositUsd, the maximum is limits.topupMaxUsd. One card runs one order at a time: while one is running the quote answers 422 ORDER_IN_PROGRESS. Orders in manual_review or awaiting_approval do not block a new one, but they keep hasOpenOrder: true and block closing the card.
Withdraw
Available when the card has canWithdraw: true. There is no on-chain payment: create the order and poll it.
const { order } = await processingRequest(key, 'POST', `/v4/${provider.id}/orders`, {
type: 'withdraw',
cardId,
amountUsd: '50',
clientId: 'your-unique-id', // a repeat returns the same order, even with other parameters
});The USDC arrives on the main account of the address; chain.creditAmountUsdc and chain.creditTxHash show the actual amount, which can be slightly below the quote, and the transaction. A withdrawal can never exceed what the address paid in to this provider.
Payment history
| Endpoint | Use |
|---|---|
GET /v4/{provider}/operations | Full history with filters and pages |
GET /v4/{provider}/cards/{cardId}/operations | The same for one card |
GET /v4/{provider}/operations/recent | The latest operations, for a main screen |
GET /v4/{provider}/operations/summary | Monthly totals and top merchants |
GET /v4/{provider}/operations/export | CSV file |
Operations arrive from the card provider with a delay of up to about a minute. A purchase starts as on_hold, then becomes approved or declined. A top-up or withdrawal goes from inited to confirmed or rollbacked.
Closing a card
POST /v4/{provider}/cards/{cardId}/deactivate with {"confirm": true} closes the card permanently. The balance must be below 1 USD and the card must have no open order. It needs the wallet's own signature, not a session one.
The balance check uses the stored snapshot, so read ?refresh=1 first. The service does not return a remainder below 1 USD: withdraw or spend it before closing.
Staying in sync
There are no webhooks. Instead of frequent polling, subscribe to change signals:
-
GET /v4/{provider}/events/channelreturnschannelId, a private key of the channel for this address. If it isnull, signals are off: stay on polling. Treat it as a secret and read it again when you reconnect. -
Subscribe on the indexer WebSocket,
wss://indexer.mainnet.rubin.trade/v4/ws(wss://indexer.testnet.rubin.trade/v4/wson testnet):{ "type": "subscribe", "channel": "v4_card", "id": "<channelId>" } -
Each update says in
contentsonly what to re-read, with no amounts or merchants. Fetch the data over the signed API:{ "type": "channel_data", "connection_id": "…", "message_id": 5, "id": "<channelId>", "channel": "v4_card", "version": "1.0.0", "contents": { "kind": "orders", "provider": "mock", "orderId": "…" } }kindRe-read May carry ordersGET /orders/{orderId}orderIdoperationsGET /operationscardIdcardGET /cards/{cardId}cardIdcodesGET /verification-codescardIdIgnore kinds you do not know. There is no snapshot on subscribe.
A connection can hold up to 8 such channels. Signals can be lost if the connection drops, so keep a slow poll as a fallback.
Suggested polling
| Data | Interval |
|---|---|
| Open order | 3–5 s |
| Verification codes during a payment | 5 s |
| Cards, operations | 30–60 s (2 min with signals on) |
| Tariffs | 60 s or less often |
reveal and refresh=1 go to the card provider, whose capacity is shared, so call them on a user action only. If the API answers PROVIDER_RATE_LIMITED or REVEAL_RATE_LIMITED, wait retryAfterSeconds.
Order-first variant
rubin.trade uses the flow above, where no order exists until the user signs. Alternatively, create the order first and get the payment details from the service:
POST /v4/{provider}/orderswithtype,feeIdorcardId+amountUsd. The answer is an order inawaiting_paymentwith apaymentblock.- Sign
MsgOrderwith exactlypayment.providerId,payment.amountQuantumsandpayment.refbeforepayment.expiresAt. - Poll the order. The service finds the payment by itself; do not call
/orders/paid.
A payment of another amount is refunded in full (payment_mismatch), and so is a late one within 7 days (payment_late). An unpaid order closes as failed (payment_timeout).
An unpaid order cannot be cancelled. Until it expires it holds one of the customer's limits.maxOpenOrders, an unpaid top-up blocks that card (ORDER_IN_PROGRESS, closing), and an unpaid issue counts toward limits.maxCards.
Testing on testnet
Use https://processing.testnet.rubin.trade. The provider there is a simulator with the id mock: cards work through the same API, but no real card exists and no real money moves. Get test USDC on the main account from the faucet:
curl -X POST https://faucet.testnet.rubin.trade/faucet/tokens \
-H "Content-Type: application/json" \
-d '{"address": "rit1…"}'When simulateOperations in GET /v4/providers is true, these endpoints are available:
GET /v4/{provider}/simulate/merchantslists the simulated merchants (public);POST /v4/{provider}/cards/{cardId}/simulatemakes an operation on your card:scenarioissuccess,pending,declined,refundorthree_ds, with optionalmerchant(an exact name from the list),country(2 uppercase letters) andamount(USD, as a string);POST /v4/{provider}/cards/{cardId}/simulationwith{"disabled": true}turns off the background purchases that the simulator makes on a new card (falseturns them back on).
The three_ds scenario produces a code in GET /verification-codes, then a purchase. These endpoints answer 404 on mainnet.