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, up to 10 s on a custodied signer). That is not the whole deadline: a cold corridor also runs an on-chain token-decimals preflight budgeted 60 s, so the worst-case server hold is about 70 s. Set your client timeout to 75 s, not just above the reply budget — a shorter one cancels nothing and discards a quote in flight.

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 is the corridor's dollar floor (default $1 of notional in the named token, or the feeable output plus its fee when that is larger on low-decimal tokens). On the testnets (BSC testnet 97, Base Sepolia 84532) the same rule scales to about $0.01 so faucet-sized balances quote. When a corridor has no usable dollar anchor, the fallback is 1 whole token on mainnet / 0.01 on testnet. Below that is a 400.
buyAmountExact-output: the exact amount you want to receive. Same dollar-floor minimum as sellAmount (default $1 on mainnet, about $0.01 on the testnets). The protocol fee is charged on the sell-token side, which this request doesn't name, so a floor-sized 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.
takerProofA signature by taker over the challenge in Proof of control. Default is EIP-712 TakerControl v1; the app's Proof of Funds v2 is accepted too (venue tries both). 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 — 5 min for "scheme": "eip191" + personal_sign (hardware wallets), or ~12 h when the proof carries nonceSecret so a Safe/custody co-sign can finish.
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.

Attribution tag ​

Attribution is opt-in. For partner keys, set includeAttribution: true to make transactions.swap.data end with an ERC-8021 attribution tag: a few bytes after the ABI-encoded call that the contract ignores and indexers read. It carries textile_api and a code for your account. The approval is plain approve calldata, no tag. Aggregator keys get no tag, since your router makes the swap an internal call where no indexer would see it.

Broadcast the data exactly as returned. To have your own builder code (a Base Builder Code, say) in the same tag, send it on /rfq/request:

json
{ "includeAttribution": true, "builderCodes": ["bc_yourcode"] }

builderCodes alone does not enable attribution. Up to 3 codes, each 1-32 characters of a-z, 0-9 and _. Don't append a second suffix yourself: indexers read only the last tag, so one of the two would be lost. Codes starting with textile_ or tp_ are reserved and get a 400.

When opted in, data.attributionSuffix returns the same hex suffix separately. It is null for aggregator keys, wallet sessions, and anonymous callers. Direct EOA transactions already carry it in transactions.swap.data; do not append it again.

For Safe, append the suffix to the fully encoded execTransaction calldata at the relayer/broadcast layer, after its signatures argument. For ERC-4337, append it to the final UserOperation.callData after encoding the account's execute/batch call and before gas estimation, hashing, and signing. An indexer must read UserOperations to attribute that payload; this does not tag the bundler's handleOps transaction. Use the wallet SDK's supported suffix hook and confirm the account accepts trailing bytes. An inner swap tag alone doesn't guarantee attribution through a hosted wallet that hides that hook.

Omitting includeAttribution or setting it to false preserves the existing transaction bytes and response fields: attributionSuffix is absent for every caller. Existing integrators need no changes. Before opting in, check any custody policies that match exact swap calldata. Approval calldata is unchanged in both modes; the expiresAt check above still applies after a delayed approval.

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 the reply budget (~750 ms typical; see Timing).
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.