Skip to content

RFQ

Five calls. preview is a free estimate; the other four work on one RFQ.

Cancel, submit and status need proof you created the RFQ: the X-Rfq-Claim header, or the API key that requested it. Anything else is a 404. See Authentication.

Preview a price

POST /v2/rfq/preview

An indicative price off the makers' latest published levels. Nothing is solicited, signed or reserved, it doesn't take an outstanding-RFQ slot, and it needs no taker. Use it while the user is typing; use request when they commit.

Same body as request, minus taker. Maker steering works here too, with or without a credential. The numbers can be a few seconds stale.

json
{
  "data": {
    "status": "preview",
    "sellAmount": "1000000000",
    "buyAmount": "612000000",
    "feeAmount": "99990",
    "takerPays": "1000000000",
    "rateRay": "612000000000000000000000000",
    "availableSellAmount": "4200000000",
    "routing": { "…": "…" }
  }
}

status is preview or no_quote, with the same reasons as a firm request, and the same reservedUntil / retryAfterMs hint when another quote is holding the liquidity. A preview is not a promise. The firm price can differ, and a size that previews fine can still come back no_quote.

Request a firm quote

POST /v2/rfq/request

This call blocks while makers answer, up to the corridor's reply budget (750 ms by default). Set your client timeout well above it.

A taker wallet is required; a credential is not. Without an API key your first firm request also carries takerProof — a short-lived signature by that wallet proving you control it (see Proof of control). One verified proof covers your requests for that wallet + chain for about 12 hours; after that, 401 proof_of_control_required tells you to sign again. preferredLiquidityWallets / restrictedLiquidityWallets are honoured without a credential (dropping them silently would price a route the caller did not ask for).

IMPORTANT

With a partner API key you never sign TakerControl. Send Authorization: Bearer <key> and leave takerProof out of the body. Your key is what makes you accountable for the taker you name, so the proof is not checked, and one sent anyway is ignored. The takerProof in the body below is for keyless, wallet-only callers such as the swap page.

Send exactly one of sellAmount (exact-input) or buyAmount (exact-output):

json
{
  "chainId": 56,
  "sellToken": "0xCNGN",
  "buyToken": "0xUSDT",
  "sellAmount": "1000000000",
  "taker": "0xYourWallet",
  "takerProof": {
    "nonce": "0x…32 random bytes…",
    "issuedAt": 1756400000000,
    "signature": "0x…"
  }
}
FieldNotes
chainIdWith an API key, must match the key's environment.
sellToken / buyTokenMust differ, and must form an enabled RFQ corridor.
sellAmountExact-input: the gross, fee-inclusive spend cap. The bundle has to cover it exactly, so the debit lands on the cap apart from the odd atomic unit the fee rounding cannot use. Read takerPays for the figure. Minimum 1 whole token of the sell asset on mainnet (or the feeable output plus its fee when that is larger on low-decimal tokens). On BSC testnet (97) the floor is 0.01 of the named token so faucet-sized USDT quotes. Below that is a 400.
buyAmountExact-output: the exact amount you want to receive. Minimum 1 whole buy token on mainnet, 0.01 on BSC testnet. The protocol fee is charged on the sell-token side, which this request doesn't name, so a 1-token buy can still come back no_valid_quote if that side is dust.
takerThe wallet that will sign and broadcast. The quote is bound to it.
takerProofAn EIP-712 signature by taker over (taker, chainId, nonce, issuedAt). Required on your first firm request unless you call with a partner API key; a verified proof then covers this wallet + chain for ~12 h and requests inside that window may omit it. issuedAt must be within 60 s of our clock. Details in Proof of control.
preferredLiquidityWalletsOptional. Solicit these makers first, then fall back to the rest. Max 10. Works without a credential.
restrictedLiquidityWalletsOptional. Solicit only these makers, no fallback. Max 10. Works without a credential.

The two steering fields are mutually exclusive. Sending both is a 400. They take maker funding wallets; duplicates are deduped, and checksummed or lowercase both work. A wallet that isn't an online maker on this corridor simply matches nothing. With restricted, that's no_restricted_liquidity.

The quote

json
{
  "data": {
    "rfqId": "rfq_…",
    "claimToken": "rfqc_…",
    "status": "quoted",
    "quote": {
      "sellAmount": "1000000000",
      "buyAmount": "612000000",
      "feeAmount": "99990",
      "takerPays": "1000000000",
      "rateRay": "612000000000000000000000000",
      "expiresAt": "2026-08-19T12:00:27.000Z",
      "orderDeadline": "2026-08-19T12:00:30.000Z",
      "latestOrderDeadline": "2026-08-19T12:00:30.000Z",
      "reactor": "0xReactor",
      "taker": "0xYourWallet",
      "encodedOrder": "0x…",
      "signature": "0x…",
      "orders": [
        {
          "encodedOrder": "0x…",
          "signature": "0x…",
          "sellAmount": "1000000000",
          "buyAmount": "612000000",
          "feeAmount": "99990",
          "maker": "0xMaker"
        }
      ]
    },
    "transactions": {
      "approval": { "to": "0xCNGN", "data": "0x095ea7b3…", "value": "0", "chainId": 56 },
      "swap":     { "to": "0xReactor", "data": "0x…", "value": "0", "chainId": 56 }
    },
    "routing": {
      "preferenceApplied": false,
      "restrictionApplied": false,
      "fallbackUsed": false,
      "targetMakerWallets": [],
      "preferredQuotesReceived": 0,
      "openMarketQuotesReceived": 2
    }
  }
}

The numbers that matter:

  • takerPays is exactly what leaves your wallet: every maker order's output plus the protocol fee on it. Never more than the sellAmount you requested. Approve this.
  • buyAmount is what you receive.
  • feeAmount is the protocol fee, already inside takerPays.

When no single maker can cover the size, Textile splits the request across makers and returns one quote whose transactions.swap is an executeBatch of every signed order. quote.orders lists them; the top-level encodedOrder / signature are just the first one.

IMPORTANT

Always broadcast transactions.swap. The top-level encodedOrder and signature are the first order only. Executing those directly under-fills every bundled quote, and you get one maker's slice instead of the size you asked for.

expiresAt is your accept cutoff: the earliest of the included makers' own cutoffs, which can be sooner than the corridor's 60 s cap. Treat it as your deadline.

The other two are about the signed orders, not about you. Each order in a bundle is signed on its own, so they can die at different times. orderDeadline is the earliest of them: past it the quote can no longer settle in full, which is what transactions.swap does. latestOrderDeadline is the last instant any single order is still executable, and it's what your outstanding-RFQ slot and your reserved funds run to. A one-maker quote has the same value in both.

Two things come back exactly once

encodedOrder / signature, and the claimToken. GET /v2/rfq/{id} never replays them. Store the claim token with the quote. It's what authorizes cancel, submit and status for this RFQ. See Authentication.

Executing it

  1. Approve takerPays of the sell token to quote.reactor. Skip it if the standing allowance already covers it. The default TTL is 60 seconds, don't spend it on a redundant approval.
  2. Send transactions.swap from the taker wallet. value is always "0"; the fee is taken in the sell token, not native.
  3. Re-check the clock before broadcasting. After expiresAt we reject /submit and stop counting this quote against the maker. The signed order can still land until orderDeadline; past that it reverts. If a slow approval pushed you past expiresAt, request a new quote. Don't send.

Quote and execute (TypeScript)

The whole flow with viem: request a firm quote with your API key, approve, swap, report the hash. The taker key here only signs and broadcasts the on-chain transactions; in a browser that's the connected wallet.

IMPORTANT

This example is the keyed path: the bearer token is what makes you accountable for the taker, so nothing signs TakerControl. Keyless callers add a takerProof to the request body instead — see Proof of control — and use the X-Rfq-Claim header where this example sends Authorization.

ts
import {
  createPublicClient,
  createWalletClient,
  encodeFunctionData,
  erc20Abi,
  http,
  type Hex,
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { bsc } from 'viem/chains'

const API = 'https://api.textilecredit.com/v2'
const chainId = 56
const sellToken = '0xCNGN' as Hex

const headers = {
  Authorization: `Bearer ${process.env.TEXTILE_API_KEY}`,
  'Content-Type': 'application/json',
}

const account = privateKeyToAccount(process.env.TAKER_PRIVATE_KEY as Hex)
const wallet = createWalletClient({ account, chain: bsc, transport: http() })
const pub = createPublicClient({ chain: bsc, transport: http() })

// 1. Firm quote. Blocks up to ~750 ms while makers answer.
const quoted = await fetch(`${API}/rfq/request`, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    chainId,
    sellToken,
    buyToken: '0xUSDT',
    sellAmount: '1000000000',
    taker: account.address,
  }),
}).then((r) => r.json())

if (quoted.data.status === 'no_quote') throw new Error(quoted.data.reason)

const { rfqId, quote, transactions } = quoted.data
const required = BigInt(quote.takerPays)
const reactor = quote.reactor as Hex

// 2. Approve takerPays to the reactor when needed, then wait for it to land.
const allowance = await pub.readContract({
  address: sellToken,
  abi: erc20Abi,
  functionName: 'allowance',
  args: [account.address, reactor],
})

if (allowance < required) {
  const { approval } = transactions
  // USDT-style tokens reject approve(nonzero → nonzero). Reset first.
  if (allowance > 0n) {
    const resetHash = await wallet.sendTransaction({
      to: sellToken,
      data: encodeFunctionData({
        abi: erc20Abi,
        functionName: 'approve',
        args: [reactor, 0n],
      }),
      value: 0n,
    })
    await pub.waitForTransactionReceipt({ hash: resetHash })
  }
  const approvalHash = await wallet.sendTransaction({
    to: approval.to as Hex,
    data: approval.data as Hex,
    value: BigInt(approval.value),
  })
  await pub.waitForTransactionReceipt({ hash: approvalHash })
}

// 3. Recheck the accept cutoff before broadcasting.
if (Date.now() >= Date.parse(quote.expiresAt)) {
  throw new Error('Quote expired during approval — request a new one')
}

const { swap } = transactions
const txHash = await wallet.sendTransaction({
  to: swap.to as Hex,
  data: swap.data as Hex,
  value: BigInt(swap.value),
})

// 4. Report the hash so we reconcile the fill and fire your webhook.
await fetch(`${API}/rfq/${rfqId}/submit`, {
  method: 'POST',
  headers,
  body: JSON.stringify({ txHash }),
})

WARNING

The approval must confirm before the swap. The reactor pulls the sell token inside executeBatch, so broadcasting both into the same block risks the swap landing first and reverting. Wait for the approval receipt, as the example does, rather than firing both transactions together.

No quote

A no-quote is a 200, not an error. It means nobody would price your trade, which is a normal outcome.

json
{
  "data": {
    "rfqId": "rfq_…",
    "claimToken": "rfqc_…",
    "status": "no_quote",
    "reason": "no_valid_quote",
    "availableSellAmount": "800000000",
    "routing": { "…": "…" }
  }
}
reasonWhat happened
no_makers_onlineNo eligible maker was connected for this corridor.
no_restricted_liquidityYou restricted to specific wallets and none of them could quote.
no_valid_quoteMakers were asked but none returned a usable quote in time.

availableSellAmount / availableBuyAmount is the published depth on the leg you named, in atomic units. It's omitted when that side is empty. If you asked for more than this, retry at this size. Textile will not fill a partial.

reservedUntil and retryAfterMs show up when somebody else's live firm quote is holding the liquidity your request would have used. The book reads thinner than the maker really is, and it comes back when that quote's accept window closes. reservedUntil is when that happens; retryAfterMs is how long to wait before asking again, with the slack for the release and the maker's republish already in it. Only reservations that could have served your request are reported, so one on the other direction, or on a maker wallet outside a restricted list, doesn't count. Both fields are absent when nothing relevant is reserved: that no-quote is a genuinely empty book, not a race.

Retrying immediately is reasonable for no_valid_quote; makers reprice constantly. Retrying no_makers_online in a tight loop is not. Wait retryAfterMs when you get one, and back off when you don't.

Release a quote

POST /v2/rfq/{id}/cancel

Drops this RFQ's claim on maker inventory and tells the maker they can quote again. Only works while the RFQ is still quoted; a submitted, filled or already-lapsed row is a 409, and an RFQ you can't prove you own is a 404.

This does not un-sign the order. It only un-counts the reservation, the same thing we do when the accept window ends unused. Your outstanding-RFQ slot stays held until the signed order deadline, so cancelling can't be used to hold more live orders than the cap.

json
{ "data": { "rfqId": "rfq_…", "status": "released" } }

Report the transaction

POST /v2/rfq/{id}/submit

Tell us the hash you broadcast so we can track it.

json
{ "txHash": "0xabc…" }

Idempotent on the same hash. Resubmitting returns the current status. A different hash for an RFQ already reported is a 409.

Reporting is a courtesy, not a requirement: settlement is reconciled from the filled order on chain, so a fill is detected whether or not you call this. If the call fails transiently, retry it, but don't treat the failure as a failed swap.

Reporting after the fill is fine

If you wait for the receipt before reporting, reconciliation may already have marked the RFQ filled. Submitting the same hash then still returns 200 with the current status. That's the happy path finishing out of order.

Read the status

GET /v2/rfq/{id}
json
{
  "data": {
    "rfqId": "rfq_…",
    "status": "filled",
    "chainId": 56,
    "sellToken": "0xCNGN",
    "buyToken": "0xUSDT",
    "sellAmount": "1000000000",
    "buyAmount": "612000000",
    "feeAmount": "99990",
    "taker": "0xYourWallet",
    "quoteTtlMs": 60000,
    "replyBy": "2026-08-19T12:00:00.750Z",
    "expiresAt": "2026-08-19T12:00:27.000Z",
    "noQuoteReason": null,
    "txHash": "0xabc…",
    "submittedAt": "2026-08-19T12:00:06.000Z",
    "filledAt": "2026-08-19T12:00:09.000Z",
    "failReason": null,
    "routing": { "…": "…" },
    "createdAt": "2026-08-19T12:00:00.000Z"
  }
}
statusMeaning
solicitingMakers are being asked. You won't normally see this.
quotedA firm quote was returned and is live.
no_quoteNobody quoted. Terminal.
submittedYou reported a transaction; we're waiting for it to settle.
filledSettled on chain. Terminal.
failedThe signed order's deadline passed without the transaction landing.
expiredThe quote lapsed without being executed. Terminal.

Once a winner exists, sellAmount and buyAmount are the settled amounts (the actual debit including fee, and the amount delivered), not the cap you asked for. Before that they echo the request. The signed order is never replayed here.

failed and expired aren't always final

A fill that indexes late corrects the record for up to 24 hours. Don't treat the first terminal status as the end of this id. GET will flip to filled and an rfq.filled webhook with late: true follows. After 24 hours both GET and the sweep stop correcting.

Prefer webhooks over polling for settlement.