Skip to content

Authentication ​

The RFQ endpoints don't need a login. A firm quote is signed to a single taker address and only that wallet can execute it on chain, so you need a wallet, not a credential. That's how the swap page works: connect a wallet, quote, settle. The one signature in the flow — proof of control, on the firm request — proves you hold that wallet; it isn't a sign-in and grants nothing beyond that request.

Two things can identify a caller:

What it isWhat it's for
Claim tokenReturned once per RFQ by POST /v2/rfq/requestOwning that one RFQ: cancel, submit, status
API keyA partner credential you create in the appYour own rate-limit budget, webhooks, partner-scoped RFQ ownership

Proof of control ​

IMPORTANT

This whole section is for keyless callers. If you send Authorization: Bearer <key>, you do not sign TakerControl at all: leave takerProof out of the request body. Your key already makes you accountable for the taker you name, so the proof is not checked, and one sent anyway is ignored. Preview never needs it either, with or without a key.

POST /v2/rfq/request locks real maker inventory for the taker you name, and we check that wallet holds the sell token (proof of funds). The funds check only means anything if you actually control that wallet — otherwise anyone could name a big public wallet they've never touched. So a firm request carries takerProof: a signature by the taker wallet over a short-lived challenge — EIP-712 by default, or the same fields as text for wallets that can't sign typed data (hardware wallets). Preview never needs it, and neither do partner-key calls (your key already makes you accountable) or Textile admin sessions.

This is deliberately not a session or a SIWE login. A login wouldn't gate anything here — signatures from throwaway wallets are free, so a session proves no scarcity. Control + funds together are what bite: passing both means quoting against a wallet you hold that's actually funded.

Sign this typed data with the taker wallet:

domain:  { name: "Textile Taker Control", version: "1", chainId: <chainId> }
type:    TakerControl { taker: address, chainId: uint256,
                        nonce: bytes32, issuedAt: uint256 }
message: taker     = the wallet your request names
         chainId   = the chain your request names
         nonce     = 32 random bytes
         issuedAt  = Unix milliseconds now

Send { nonce, issuedAt, signature } as takerProof in the request body.

The Proof of Funds version ​

The Textile app asks wallets for a second version of the same challenge, which wallets show as Textile Proof of Funds. It binds the same four values, adds a fixed purpose sentence so a person (or each Safe co-signer) can read what they're signing, and names the address field wallet:

domain:  { name: "Textile Proof of Funds", version: "2", chainId: <chainId> }
type:    ProofOfFunds { purpose: string, wallet: address, chainId: uint256,
                        nonce: bytes32, issuedAt: uint256 }
message: purpose   = "Proves you control this wallet and its funds. Moves no funds and approves no transfer, trade or allowance."
         wallet    = the wallet your request names
         chainId, nonce, issuedAt as above

We accept either version on every endpoint that takes a proof, and there's no field to say which one you signed: we check both. Keep signing TakerControl if that's what you have. If people sign with their own wallets in your product, the Proof of Funds version is the one they'll understand. Its eip191 text (see Hardware wallets for the rules) is:

Textile Proof of Funds
Version: 2

Sign to prove you control this wallet and its funds.
This moves no funds and approves no transfer, trade or allowance.

Wallet: <wallet, lowercased>
Chain: <chainId>
Nonce: <nonce, lowercased>
Issued at: <issuedAt>

Any EIP-712 signer produces it: signTypedData on a viem wallet client, an ethers Signer, or the connected browser wallet. You only sign once per session: after the first proof verifies, requests for that wallet + chain go out without takerProof for about 12 hours (see One signature per ~12 hours). Inside the 60-second window the identical signed proof is also accepted again from the same caller, so a quick re-quote never needs a second prompt.

Rules:

  • issuedAt must be within 60 seconds of our clock, either side — 5 minutes for an eip191 proof (see Hardware wallets). Wallet reads and firm requests can stretch that to hours when they carry a nonceSecret (see Safe and custody wallets).
  • The nonce is bound to that one signed challenge and to the caller that first sent it (your API key, session, or client IP). Re-sending the identical proof yourself while it's fresh is fine — but a nonce reused with different contents, or the same proof arriving from a different caller, is refused.
  • The signature must verify as taker exactly. EOAs verify by ecrecover; smart accounts (Safe, 4337) verify on-chain via EIP-1271, including counterfactual ones through ERC-6492. EIP-1271 runs on every settlement chain the product trades on, not only the app's lending chains.

A missing or bad proof is a 401 with a machine-readable details.reason; see Errors.

Signing it ​

A complete Node example with viem. The private key is only there to keep the example short. Any signer that can do EIP-712 builds the same payload: a browser wallet client, an ethers Signer, a custody SDK.

ts
import crypto from 'node:crypto'
import { toHex, type Hex } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'

// The wallet you name as `taker` (RFQ) or `wallet` (Ramp).
const account = privateKeyToAccount(process.env.TAKER_PRIVATE_KEY as Hex)

async function signTakerControl(chainId: number) {
  const nonce = toHex(crypto.randomBytes(32)) // fresh for every challenge
  const issuedAt = Date.now() // Unix milliseconds, not seconds

  const signature = await account.signTypedData({
    domain: { name: 'Textile Taker Control', version: '1', chainId },
    types: {
      TakerControl: [
        { name: 'taker', type: 'address' },
        { name: 'chainId', type: 'uint256' },
        { name: 'nonce', type: 'bytes32' },
        { name: 'issuedAt', type: 'uint256' },
      ],
    },
    primaryType: 'TakerControl',
    message: {
      taker: account.address,
      chainId: BigInt(chainId),
      nonce,
      issuedAt: BigInt(issuedAt),
    },
  })

  return { nonce, issuedAt, signature }
}

On POST /v2/rfq/request, send the request unsigned first and sign only when asked. Inside a live grant the first call goes through and the wallet never opens:

ts
const API = 'https://api.textilecredit.com/v2'

async function requestFirmQuote(body: { chainId: number; [k: string]: unknown }) {
  const send = (extra: object = {}) =>
    fetch(`${API}/rfq/request`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ ...body, taker: account.address, ...extra }),
      signal: AbortSignal.timeout(75_000),
    })

  let res = await send()
  if (res.status === 401) {
    const { error } = await res.clone().json()
    if (error.details?.reason === 'proof_of_control_required') {
      res = await send({ takerProof: await signTakerControl(body.chainId) })
    }
  }
  return res
}

The Ramp API has no grant, so there you sign up front and put the result in proof. See Customers for a ramp example.

Which endpoints need it ​

A partner API key replaces the proof on the RFQ API only. The Ramp API has no API keys: it ignores an Authorization header, and its wallet endpoints ask for a signature whoever calls them.

EndpointFieldWithout a keyWith a partner key
POST /v2/rfq/requesttakerProofRequired, unless a grant covers this wallet + chainNot needed. Ignored if sent
POST /v2/rfq/preview, POST /v2/rfq/{id}/submit, POST /v2/rfq/{id}/cancel, GET /v2/rfq/{id}noneNot neededNot needed
POST /v2/ramp/customers, /customers/kyc, /customers/kyc/status, /customers/kyc/linkproofRequired on every callRequired on every call
POST /v2/ramp/transfers, /transfers/{id}/retry-payout/check, /transfers/{id}/retry-payoutproofRequired on every callRequired on every call
POST /v2/ramp/customers/details, /transfers/list, /transfers/{id}/walletproofRequired, until the activityToken or wallet session from a proved call covers itSame

The ramp endpoints never pick up a grant from an earlier RFQ proof. Every ramp write needs a signature that's still inside its window. Within those 60 seconds you can send the same proof to several ramp calls from the same client, for example /customers and then /transfers.

Hardware wallets: the eip191 encoding ​

A Ledger cannot sign the typed data above. It clear-signs only EIP-712 messages it holds a descriptor for, keyed on the domain's verifyingContract — this challenge has none, because it authenticates a wallet rather than calling a contract. So the device falls through to blind signing, which ships disabled, and answers 0x6a80. Every hardware taker would be locked out of the firm path.

For those wallets, sign the same four fields rendered as text with personal_sign and add scheme: "eip191" to takerProof. The text is newline-separated, with the wallet address and nonce lowercased:

Textile Taker Control
Version: 1

Sign to prove you control this wallet so Textile can request a firm
quote for it. This does not approve any transfer, trade or allowance.

Wallet: <taker, lowercased>
Chain: <chainId>
Nonce: <nonce, lowercased>
Issued at: <issuedAt>

Then send:

json
"takerProof": {
  "nonce": "0x…",
  "issuedAt": 1756400000000,
  "signature": "0x…",
  "scheme": "eip191"
}

Build the string byte for byte — we rebuild it to recover the signer, so one stray space or a checksummed address is a failed recovery, not a warning.

With viem, reusing account from Signing it:

ts
async function signTakerControlText(chainId: number) {
  const nonce = toHex(crypto.randomBytes(32))
  const issuedAt = Date.now()
  const message = [
    'Textile Taker Control',
    'Version: 1',
    '',
    'Sign to prove you control this wallet so Textile can request a firm',
    'quote for it. This does not approve any transfer, trade or allowance.',
    '',
    `Wallet: ${account.address.toLowerCase()}`,
    `Chain: ${chainId}`,
    `Nonce: ${nonce.toLowerCase()}`,
    `Issued at: ${issuedAt}`,
  ].join('\n')

  const signature = await account.signMessage({ message }) // personal_sign
  return { nonce, issuedAt, signature, scheme: 'eip191' as const }
}

The scheme is an encoding choice and grants nothing. Both forms bind the identical (taker, chainId, nonce, issuedAt) and both must recover to taker, so claiming the wrong one just fails to verify. Nonce binding, grants and EIP-1271 all behave the same.

One difference: an eip191 proof is valid for 5 minutes instead of 60 seconds. issuedAt is fixed before the device prompt opens, and a Ledger paginates this text over seven screens that someone has to click through before a signature exists — at 60 seconds a careful reader would sign something we had already expired. Omitting scheme, or sending eip712, keeps the 60-second window.

Safe and custody wallets: nonceSecret ​

Ramp writes keep the short window above. A wallet read (activity history, stored contact details, transfer list) and a firm RFQ request can wait hours for a Safe's owners or a custody policy to finish co-signing. For those call sites:

  1. Pick a 32-byte secret and set nonce = keccak256(secret).
  2. Sign the usual challenge over that nonce.
  3. Send { nonce, issuedAt, signature, nonceSecret: secret } as proof (or takerProof). Keep the secret in the signing browser; never publish it.

When the secret hashes to the nonce, the venue accepts the proof for 24 hours after issuedAt on reads, and for the grant window (about 12 hours) on POST /v2/rfq/request. The future side of the clock never widens. A lifted Safe signature without the secret still gets the usual 60 seconds, since Safe's transaction service publishes fully signed off-chain messages. Ramp writes never take nonceSecret.

The firm-quote window matches the grant on purpose: a proof the owners finished co-signing five hours ago, presented with its secret, says no more than the grant you'd hold had they signed at once. Funds are still checked live on every request.

One signature per ~12 hours ​

You don't sign every request. A verified proof earns a grant: for about 12 hours, firm requests naming the same taker on the same chain may omit takerProof entirely. The grant is bound to the caller that proved it — your session, or for anonymous callers your client IP — so nobody else can ride it, and proving control of your own wallet never lets anyone name it from elsewhere. (Partner keys skip the proof altogether, so they never hold one.)

Don't track the window client-side. Send the request unsigned; if the grant has lapsed (TTL, an IP change, our cache forgetting) you get 401 proof_of_control_required, which is your cue to sign once and resend. That 401 is answered before any maker is solicited or inventory locked, so the retry costs nothing but the rate-limit slot the refused call used. The signature itself still expires in a minute (five for eip191) — the grant is state on our side, never a long-lived credential on the wire.

The claim token ​

POST /v2/rfq/request returns a claimToken alongside the rfqId. Send it as X-Rfq-Claim on cancel, submit and status for that RFQ:

bash
curl -X POST https://api.textilecredit.com/v2/rfq/rfq_abc.../cancel \
  -H "X-Rfq-Claim: rfqc_your_token_here"

It comes back exactly once, so store it with the quote. Without an API key it's the only thing that authorizes those calls; a wrong or missing token is a 404. With a key, either works.

The rfqId is not a secret and authorizes nothing. Every maker we solicit receives it in their quote request, losers included, so an id-only rule would let a maker cancel or report someone else's RFQ. The claim token never leaves our side except in that one response, and we store only its hash.

API keys ​

Send the key as a bearer token:

bash
curl -X POST https://api.textilecredit.com/v2/rfq/request \
  -H "Authorization: Bearer tx_live_a1b2c3d4.your_secret_here" \
  -H "Content-Type: application/json" \
  -d '{ ... }'

The X-API-Key: <key> header works too.

A key looks like tx_live_a1b2c3d4.<secret>. The part before the dot is the public prefix, safe to log. The part after is the secret; we only store a hash of it, so we show you the full key exactly once. Lost keys get revoked and reissued, not recovered. Never ship a live secret in client-side code.

What a key buys you:

  • A rate-limit budget of your own, keyed to the key rather than to your client IP, and a higher tier if you need one.
  • Webhooks: terminal RFQ events delivered to your endpoints.
  • Partner-scoped ownership: any key on your partner record can read and manage the RFQs it created, without the claim token.

Maker steering (preferredLiquidityWallets / restrictedLiquidityWallets) works with or without a key. The lists are honoured for any caller so a shared deep link prices the route it names.

Create one yourself from the profile menu: connect your wallet in the app, click your address in the top right, and pick API keys. How to get an API key has the steps. Any live partner key reaches these endpoints; a test key (tx_test_…) reaches them on BSC testnet and Base Sepolia only — see Test vs live.

Maker credentials are the one exception. They look the same (tx_live_…) but are bound to a maker and carry only maker:* scopes, and the taker endpoints refuse them with a 403. They belong on WSS /v2/maker/stream.

Scopes ​

Each key carries a set of scopes. These are the ones that matter here:

ScopeGrants
trades:writePOST /v2/rfq/request, /cancel, /submit
trades:readGET /v2/rfq/{id}
rfq:aggregatorRouter-as-taker RFQ: skips the proof-of-funds check on /request (a router never holds the sell token at quote time) and raises the outstanding-RFQ cap from 4 to 32. Issued by hand — see For aggregators.

A key missing the scope for an endpoint gets 403 forbidden. Scopes only apply to key-authenticated calls. An anonymous caller holding a claim token isn't scoped.

Test vs live ​

Keys are environment-scoped and the environment is baked into the prefix: tx_live_… for mainnet corridors, tx_test_… for testnet. A test key against a mainnet chain (or the reverse) is a 400.

IMPORTANT

A test key works on two chains only: BNB Smart Chain testnet (chainId: 97, cNGN ↔ USDT) and Base Sepolia (chainId: 84532, cNGN ↔ USDC and ZARU ↔ USDC). Those are the testnets we have deployed settlement contracts to. Sepolia, Celo Sepolia and the rest have no reactor, so a tx_test_… key naming one of those returns 400 saying the chain has no FX settlement contract. If your test key looks broken, check the chainId first.

See Testnet for faucets, addresses and setup. A corridor still has to be enabled there with a maker online, so if you get no_makers_online on testnet, tell us rather than assuming your request is wrong.

No credential at all ​

GET /v2/health and GET /v2/openapi.json are fully public, so monitors and codegen can reach them.