Skip to content

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, history

Issuing 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);
FieldUse
idThe {provider} path segment
providerAvailablefalse — the card provider is not responding. Do not offer new orders: an on-chain payment would wait in paid until the provider returns
feesTariffs. Offer only active: true; active: false ones are kept to show the terms of cards issued earlier
limitsCards per address, open orders per address, top-up and withdrawal bounds
flowWhat this provider supports: issue, topup, withdraw, password, rename, …
chain.providerIdThe provider_id for MsgOrder. It can differ from id, so do not mix them
chain.enabledfalse — the chain rejects payments to this provider; do not offer issue or top-up
chain.maxOrderUsdcThe 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:

CheckIf you skip it
Issue: limits.maxCards against active cards plus open issue ordersThe payment returns as order_rejected, minus the network fee
limits.maxOpenOrders against GET /orders?status=openThe same
Issue: flow.issueThe same
chain.enabled, chain.maxOrderUsdcThe chain rejects the transaction
providerAvailableThe 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:

Operationref
Issueo1:i:{feeId}:{nonce}
Top upo1:t:{cardId}:{amountUsd}:{nonce}
  • nonce — 16 lowercase hex characters, new for every attempt. Save the ref before signing and reuse it when retrying the same attempt: the chain refuses a second payment with the same ref, 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.

If sending fails
ErrorMeaningWhat to do
ref already used for the providerThis payment went through earlierGo to step 5
was submitted but was not yet found on the chain (timeout)The payment may still landRetry with the same ref, or go to step 5
insufficient fundsNot enough USDC, nothing movedFund the address, retry with the same ref
amount exceeds max_order_amount of the provider, provider daily limit exceeded, processing provider is disabledRejected, nothing movedDo 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.

StatusApplies toMeaning
awaiting_paymentissue, top upWaits for MsgOrder (order-first variant)
acceptedwithdrawAccepted, queued for the card provider
paidissue, top upPayment found on chain, queued for the card provider
provider_pendingallIn progress at the card provider
credit_submittedwithdrawThe card was debited, USDC is being credited to the address
awaiting_approvalwithdrawAbove the provider's automatic limits, waits for a second signature on the provider's side. No action needed; it can take longer
refund_submittedissue, top upThe payment is being returned to the address
completedallDone
refundedissue, top upThe whole payment returned to the address, see failReason
failedallClosed without execution, see failReason
cancelledallClosed by support
manual_reviewallNeeds support — contact us with the orderId
failReasonMeaning
order_rejectedThe payment arrived, but the operation is no longer allowed: tariff taken off sale, card limit reached, another order on this card. Refunded
payment_mismatchThe 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_timeoutOrder-first variant: no payment before payment.expiresAt
payment_lateOrder-first variant: the payment came after the order closed. Refunded
provider_rejectedThe card provider refused. Issue, top-up: refunded. Withdrawal: failed, the card is not debited
price_driftThe card provider's price moved beyond the tolerance. Issue, top-up: refunded. Withdrawal: failed, the card is not debited
max_attemptsThe 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 order

amountUsd 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

EndpointUse
GET /v4/{provider}/operationsFull history with filters and pages
GET /v4/{provider}/cards/{cardId}/operationsThe same for one card
GET /v4/{provider}/operations/recentThe latest operations, for a main screen
GET /v4/{provider}/operations/summaryMonthly totals and top merchants
GET /v4/{provider}/operations/exportCSV 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:

  1. GET /v4/{provider}/events/channel returns channelId, a private key of the channel for this address. If it is null, signals are off: stay on polling. Treat it as a secret and read it again when you reconnect.

  2. Subscribe on the indexer WebSocket, wss://indexer.mainnet.rubin.trade/v4/ws (wss://indexer.testnet.rubin.trade/v4/ws on testnet):

    { "type": "subscribe", "channel": "v4_card", "id": "<channelId>" }
  3. Each update says in contents only 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-readMay carry
    ordersGET /orders/{orderId}orderId
    operationsGET /operationscardId
    cardGET /cards/{cardId}cardId
    codesGET /verification-codescardId

    Ignore 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

DataInterval
Open order3–5 s
Verification codes during a payment5 s
Cards, operations30–60 s (2 min with signals on)
Tariffs60 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:

  1. POST /v4/{provider}/orders with type, feeId or cardId + amountUsd. The answer is an order in awaiting_payment with a payment block.
  2. Sign MsgOrder with exactly payment.providerId, payment.amountQuantums and payment.ref before payment.expiresAt.
  3. 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/merchants lists the simulated merchants (public);
  • POST /v4/{provider}/cards/{cardId}/simulate makes an operation on your card: scenario is success, pending, declined, refund or three_ds, with optional merchant (an exact name from the list), country (2 uppercase letters) and amount (USD, as a string);
  • POST /v4/{provider}/cards/{cardId}/simulation with {"disabled": true} turns off the background purchases that the simulator makes on a new card (false turns them back on).

The three_ds scenario produces a code in GET /verification-codes, then a purchase. These endpoints answer 404 on mainnet.