Keys & Authentication
Cards have no API keys. A customer is a Rubin address, and the key of that address does everything: it signs API requests, pays for orders on chain, and receives refunds and withdrawals. The key is non-custodial: it is generated and kept on your side or the user's, and Rubin has no copy of it.
Generate a key
A Rubin key is an ordinary secp256k1 key. It is generated offline and registered nowhere; the address derived from it becomes the customer.
| Situation | What to use |
|---|---|
| You keep a key per end user | Generate one as shown below |
| The user connects an EVM wallet | Derive the key from a wallet signature, the same way rubin.trade does: Onboarding |
| The user already has a Rubin account | Use its mnemonic: Wallet Setup |
| You already hold EVM keys | Use the EVM key directly: the evm scheme for requests and EthLocalWallet for payments; see Addresses |
To generate a key with @ritbit/v4-client-js:
import { randomBytes } from 'node:crypto';
import { BECH32_PREFIX, LocalWallet, onboarding } from '@ritbit/v4-client-js';
// 32 bytes of entropy give a 24-word mnemonic; the key uses the path m/44'/118'/0'/0/0.
const { mnemonic, privateKey } = onboarding.exportMnemonicAndPrivateKey(randomBytes(32));
const wallet = await LocalWallet.fromMnemonic(mnemonic, BECH32_PREFIX);
console.log(wallet.address); // rit1…wallet pays on chain; privateKey (Uint8Array | null in the types, set for a valid mnemonic) signs API requests in the example below. Both belong to the same address.
Give each end user their own key. Cards, orders, history and the per-address limits (limits.maxCards, limits.maxOpenOrders in GET /v4/providers) are all counted per address.
Fund the address
An address does not exist on chain until it first receives funds. Before that, signed API requests work, but MsgOrder fails with account … not found.
Send USDC to the address through Onramp, a bridge deposit or a transfer from another Rubin address. On testnet use the faucet.
The USDC must be on the main account of the address, which is where deposits land. Network fees are also paid in USDC: a card payment costs about 0.004 USDC, so keep a small amount above the price. Funds on a trading subaccount can be moved in the same transaction, but the network fee must already be on the main account.
Sign requests
Every customer endpoint is authorized by a signature of the customer's key. The address in the request is the customer: a card or order of another address answers 404.
GET /health and GET /v4/providers need no signature.
Headers
| Header | Required | Value |
|---|---|---|
x-processing-address | always | Customer address, bech32 rit1… (lowercase, 20-byte payload) |
x-processing-timestamp | always | Unix time in seconds |
x-processing-signature | always | Signature of the message below; encoding depends on the wallet type |
x-processing-wallet-type | no (default cosmos) | cosmos, keplr, evm or session — see Signature schemes |
x-processing-pubkey | for cosmos and keplr | Base64 of the 33-byte compressed secp256k1 public key |
Content-Type | for requests with a body | application/json |
Signed message
RITBIT_PROCESSING:{METHOD}:{path}:{bodyHash}:{timestamp}| Part | Rule |
|---|---|
METHOD | Uppercase HTTP method: GET or POST |
path | Full request path including /v4 and without the query string, e.g. /v4/mock/cards |
bodyHash | Lowercase hex SHA-256 of the exact body bytes you send; empty string for GET and for requests without a body |
timestamp | The same value as x-processing-timestamp |
Examples:
RITBIT_PROCESSING:GET:/v4/mock/cards::1791200000
RITBIT_PROCESSING:POST:/v4/mock/orders/paid:3b7a…e91c:1791200000Validity window and replays
The server accepts a timestamp within ±120 seconds of its own clock.
GET: the same signature stays valid for the same path for the whole window, so a polling client may reuse it.POST: every signature is accepted once. A repeat answers401 Replay detected. Sign eachPOSTafresh, including retries. With thecosmosscheme the signature is deterministic, so two identicalPOSTs in the same second produce the same signature: wait for the next second before retrying.
Signature schemes
x-processing-wallet-type | Who signs | Use it when |
|---|---|---|
cosmos | A secp256k1 key held by your backend or app | Recommended for partner integrations |
evm | An EVM key or wallet, EIP-712 typed data | You already hold EVM keys, or the user signs in an EVM wallet |
keplr | Keplr wallet, ADR-36 signArbitrary | The end user connects a Keplr wallet |
session | On-chain trading keys | Used by the rubin.trade web app; contact us before relying on it |
Closing a card (POST …/deactivate) is irreversible and does not accept session signatures.
cosmos scheme
- Build the signed message as above.
- Compute
digest = SHA-256(message + ':PROCESSING":' + timestamp). Note the literal double quote afterPROCESSING: the string is{message}:PROCESSING":{timestamp}. - Sign
digestwith secp256k1. Send the signature as base64 of the 65-byte fixed-length formr ‖ s ‖ recovery. - Send the compressed public key in
x-processing-pubkey. The server derivesbech32("rit", ripemd160(sha256(pubkey)))and rejects the request if it differs fromx-processing-address.
Example (TypeScript)
import { ripemd160, Secp256k1, sha256 } from '@cosmjs/crypto';
import { toBech32, toHex } from '@cosmjs/encoding';
const BASE_URL = 'https://processing.testnet.rubin.trade';
const enc = new TextEncoder();
export async function processingRequest(
privateKey: Uint8Array,
method: 'GET' | 'POST',
pathWithQuery: string,
body?: unknown,
) {
const { pubkey } = await Secp256k1.makeKeypair(privateKey);
const compressed = Secp256k1.compressPubkey(pubkey);
const address = toBech32('rit', ripemd160(sha256(compressed)));
const rawBody = body === undefined ? '' : JSON.stringify(body);
const bodyHash = rawBody === '' ? '' : toHex(sha256(enc.encode(rawBody)));
const timestamp = Math.floor(Date.now() / 1000);
const path = pathWithQuery.split('?')[0];
const message = `RITBIT_PROCESSING:${method}:${path}:${bodyHash}:${timestamp}`;
const digest = sha256(enc.encode(`${message}:PROCESSING":${timestamp}`));
const signature = await Secp256k1.createSignature(digest, privateKey);
const res = await fetch(`${BASE_URL}${pathWithQuery}`, {
method,
headers: {
'x-processing-address': address,
'x-processing-timestamp': String(timestamp),
'x-processing-wallet-type': 'cosmos',
'x-processing-pubkey': Buffer.from(compressed).toString('base64'),
'x-processing-signature': Buffer.from(signature.toFixedLength()).toString('base64'),
...(rawBody === '' ? {} : { 'Content-Type': 'application/json' }),
},
body: rawBody === '' ? undefined : rawBody,
});
const data = await res.json();
if (!res.ok) throw Object.assign(new Error(data.errors?.[0]?.msg), { status: res.status, data });
return data;
}
// const { cards } = await processingRequest(privateKey!, 'GET', '/v4/mock/cards');GET /operations/export answers CSV, not JSON: read it with res.text().
evm scheme
A Rubin address and an EVM address are two encodings of the same 20 bytes: rit1… is bech32("rit", bytes), 0x… is their hex. An EVM key therefore owns the Rubin address of the same bytes and can sign for it directly.
-
Build the signed message as above.
-
Sign EIP-712 typed data with exactly this domain and type. Any difference changes the digest:
Field Value domain { name: 'Rubin', version: '1' }— nochainId, noverifyingContractprimary type AccountAction(string action,string message,uint64 timestamp)actionPROCESSINGmessageThe signed message from step 1 timestampThe same value as x-processing-timestamp -
Send the 65-byte signature as
0x-prefixed hex inx-processing-signature. Nox-processing-pubkey: the server recovers the signer from the signature and compares its 20 bytes withx-processing-address.
import { toBech32 } from '@cosmjs/encoding';
import { hexToBytes } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
const account = privateKeyToAccount(evmPrivateKey);
const address = toBech32('rit', hexToBytes(account.address)); // x-processing-address
// message and timestamp built exactly as in the cosmos example above
const signature = await account.signTypedData({
domain: { name: 'Rubin', version: '1' },
types: {
AccountAction: [
{ name: 'action', type: 'string' },
{ name: 'message', type: 'string' },
{ name: 'timestamp', type: 'uint64' },
],
},
primaryType: 'AccountAction',
message: { action: 'PROCESSING', message, timestamp: BigInt(timestamp) },
});
// headers: x-processing-address, x-processing-timestamp,
// x-processing-wallet-type: 'evm', x-processing-signature: signatureFor on-chain payments use EthLocalWallet.fromPrivateKey(evmPrivateKey, BECH32_PREFIX) from the client. LocalWallet would derive a different address from the same key.
keplr scheme
Sign the message from above with signArbitrary (ADR-36). Send the signature in base64 in x-processing-signature and the public key in x-processing-pubkey.
Authentication errors
| HTTP | Message | Cause |
|---|---|---|
401 | Missing processing auth headers | No address, signature or timestamp header |
401 | Invalid processing auth timestamp | The timestamp is not a number |
401 | Replay detected | This POST signature was already used |
400 | Address … is not a valid rit address | The address is not a valid rit1… address |
400 | Timestamp is not within the valid range of 120 seconds | Clock skew or a stale signature |
400 | Address … does not correspond to the pubkey provided … | x-processing-pubkey does not hash to the address |
400 | Address … does not correspond to the signer … | evm: the recovered signer is another address — usually a wrong domain, type or message |
400 | Signature verification failed | Wrong message, wrong digest or a re-serialized body |
400 | Keplr signature verification failed | keplr: wrong message, signature or public key |
503 | Cannot verify request right now | A POST could not be checked for replay; retry with a new signature |
403 | This action must be signed by the wallet, not a session key (WALLET_SIGNATURE_REQUIRED) | session signature on POST …/deactivate |
Errors use the common error format.