Skip to content

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.

SituationWhat to use
You keep a key per end userGenerate one as shown below
The user connects an EVM walletDerive the key from a wallet signature, the same way rubin.trade does: Onboarding
The user already has a Rubin accountUse its mnemonic: Wallet Setup
You already hold EVM keysUse 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

HeaderRequiredValue
x-processing-addressalwaysCustomer address, bech32 rit1… (lowercase, 20-byte payload)
x-processing-timestampalwaysUnix time in seconds
x-processing-signaturealwaysSignature of the message below; encoding depends on the wallet type
x-processing-wallet-typeno (default cosmos)cosmos, keplr, evm or session — see Signature schemes
x-processing-pubkeyfor cosmos and keplrBase64 of the 33-byte compressed secp256k1 public key
Content-Typefor requests with a bodyapplication/json

Signed message

RITBIT_PROCESSING:{METHOD}:{path}:{bodyHash}:{timestamp}
PartRule
METHODUppercase HTTP method: GET or POST
pathFull request path including /v4 and without the query string, e.g. /v4/mock/cards
bodyHashLowercase hex SHA-256 of the exact body bytes you send; empty string for GET and for requests without a body
timestampThe same value as x-processing-timestamp

Examples:

RITBIT_PROCESSING:GET:/v4/mock/cards::1791200000
RITBIT_PROCESSING:POST:/v4/mock/orders/paid:3b7a…e91c:1791200000

Validity 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 answers 401 Replay detected. Sign each POST afresh, including retries. With the cosmos scheme the signature is deterministic, so two identical POSTs in the same second produce the same signature: wait for the next second before retrying.

Signature schemes

x-processing-wallet-typeWho signsUse it when
cosmosA secp256k1 key held by your backend or appRecommended for partner integrations
evmAn EVM key or wallet, EIP-712 typed dataYou already hold EVM keys, or the user signs in an EVM wallet
keplrKeplr wallet, ADR-36 signArbitraryThe end user connects a Keplr wallet
sessionOn-chain trading keysUsed 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

  1. Build the signed message as above.
  2. Compute digest = SHA-256(message + ':PROCESSING":' + timestamp). Note the literal double quote after PROCESSING: the string is {message}:PROCESSING":{timestamp}.
  3. Sign digest with secp256k1. Send the signature as base64 of the 65-byte fixed-length form r ‖ s ‖ recovery.
  4. Send the compressed public key in x-processing-pubkey. The server derives bech32("rit", ripemd160(sha256(pubkey))) and rejects the request if it differs from x-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.

  1. Build the signed message as above.

  2. Sign EIP-712 typed data with exactly this domain and type. Any difference changes the digest:

    FieldValue
    domain{ name: 'Rubin', version: '1' } — no chainId, no verifyingContract
    primary typeAccountAction(string action,string message,uint64 timestamp)
    actionPROCESSING
    messageThe signed message from step 1
    timestampThe same value as x-processing-timestamp
  3. Send the 65-byte signature as 0x-prefixed hex in x-processing-signature. No x-processing-pubkey: the server recovers the signer from the signature and compares its 20 bytes with x-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: signature

For 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

HTTPMessageCause
401Missing processing auth headersNo address, signature or timestamp header
401Invalid processing auth timestampThe timestamp is not a number
401Replay detectedThis POST signature was already used
400Address … is not a valid rit addressThe address is not a valid rit1… address
400Timestamp is not within the valid range of 120 secondsClock skew or a stale signature
400Address … does not correspond to the pubkey provided …x-processing-pubkey does not hash to the address
400Address … does not correspond to the signer …evm: the recovered signer is another address — usually a wrong domain, type or message
400Signature verification failedWrong message, wrong digest or a re-serialized body
400Keplr signature verification failedkeplr: wrong message, signature or public key
503Cannot verify request right nowA POST could not be checked for replay; retry with a new signature
403This 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.