```javascript
diff --git a/openapi-v1.yaml b/openapi-v1.yaml
index de815ef..480582b 100644
--- a/openapi-v1.yaml
+++ b/openapi-v1.yaml
@@ -68,7 +68,27 @@ components:
description: Memo or reference for the transaction
providerId:
type: string
- description: Optional provider ID for specific routing
+ description: |
+ Optional provider ID for specific routing. Retained for backward compatibility —
+ prefer `providerIds`, which expresses the same thing as a one-entry queue.
+ providerIds:
+ type: array
+ writeOnly: true
+ maxItems: 3
+ items:
+ type: string
+ pattern: '^[A-Za-z]{8}$'
+ description: |
+ Optional ordered provider queue (max 3). Assignment tries entries in order, moving to the next
+ when one declines, expires, or exhausts its retries. Mutually exclusive with `providerId` —
+ sending both returns **400**.
+ A one-entry queue is exactly a single-provider pin, so `providerIds: [X]` routes like
+ `providerId: X` for every provider you can pin.
+ Multi-entry queues must name providers that can serve this corridor: active, with a live
+ standard offer, and — for a provider restricted to specific senders — one whose list
+ includes you. The fallback provider may be listed but is ignored; it is already tried
+ after the queue is exhausted.
+ Accepted on create only; order responses and webhooks return the singular assigned `providerId`.
metadata:
type: object
description: Additional metadata for the transaction
diff --git a/openapi-v2.yaml b/openapi-v2.yaml
index 7c9163f..7a77da5 100644
--- a/openapi-v2.yaml
+++ b/openapi-v2.yaml
@@ -525,15 +525,21 @@ components:
properties:
rate:
type: string
- description: Achievable rate (fiat per crypto) for this side and notional.
+ description: Achievable rate (fiat per crypto) for this side and notional. This is the rate of `providerIds[0]`.
providerIds:
type: array
+ maxItems: 3
items:
type: string
- description: Provider id(s) tied to the quote (single id when `provider_id` was requested).
+ description: |
+ Ranked provider queue for this side, best first — the order assignment would try them in.
+ Up to 3 ids by default; use `limit` to request fewer. `providerIds[0]` is the provider whose rate
+ is quoted in `rate`. Returns a single id when `provider_id` pins the quote. The fallback provider
+ is never listed. Always an array — `[]` when no provider is available, never `null`. Pass these ids
+ to `destination.providerIds` on order creation to lock routing to the providers you quoted.
orderType:
type: string
- description: Order flow type, e.g. `regular` or `otc`.
+ description: Order flow type, e.g. `regular`.
refundTimeoutMinutes:
type: integer
description: Minutes until automatic refund for this flow.
@@ -857,7 +863,28 @@ components:
description: ISO 3166-1 alpha-2 country code (optional)
providerId:
type: string
- description: Pin order to a specific provider (optional)
+ description: |
+ Pin order to a single provider (optional). Retained for backward compatibility —
+ prefer `providerIds`, which expresses the same thing as a one-entry queue.
+ providerIds:
+ type: array
+ writeOnly: true
+ maxItems: 3
+ items:
+ type: string
+ pattern: '^[A-Za-z]{8}$'
+ description: |
+ Optional ordered provider queue (max 3). Assignment tries entries in order, moving to the next
+ when one declines, expires, or exhausts its retries. Typically the `providerIds` from a
+ [rate quote](/api-reference/general/get-token-rate).
+ Mutually exclusive with `providerId` — sending both returns **400**.
+ A one-entry queue is exactly a single-provider pin, so `providerIds: [X]` routes like
+ `providerId: X` for every provider you can pin.
+ Multi-entry queues must name providers that can serve this corridor: active, with a live
+ standard offer, and — for a provider restricted to specific senders — one whose list
+ includes you. The fallback provider may be listed but is ignored; it is already tried
+ after the queue is exhausted.
+ Accepted on create only; order responses and webhooks return the singular assigned `providerId`.
kyc:
type: object
description: Optional destination (recipient) KYC for offramp. Do not send sender or provider KYB on offramp create.
@@ -906,6 +933,23 @@ components:
description: Stablecoin symbol (e.g. USDT, USDC)
providerId:
type: string
+ description: |
+ Pin order to a single provider (optional). Retained for backward compatibility —
+ prefer `providerIds`.
+ providerIds:
+ type: array
+ writeOnly: true
+ maxItems: 1
+ items:
+ type: string
+ pattern: '^[A-Za-z]{8}$'
+ description: |
+ Optional provider pin in queue form, for parity with offramp. Onramp selects its provider
+ when the order is created rather than walking a queue, so **at most one entry** is
+ accepted — more returns **400**. Mutually exclusive with `providerId`.
+ The provider must be one you can route to: pinning a provider restricted to other
+ senders returns **400**.
+ Accepted on create only; order responses and webhooks return the singular assigned `providerId`.
recipient:
$ref: '#/components/schemas/V2CryptoRecipient'
@@ -968,7 +1012,7 @@ components:
type: string
orderType:
type: string
- enum: [regular, otc]
+ enum: [regular]
timestamp:
type: string
format: date-time
@@ -1011,7 +1055,7 @@ components:
enum: [initiated, deposited, pending, fulfilling, fulfilled, validated, settling, settled, cancelled, refunding, refunded, expired]
orderType:
type: string
- enum: [regular, otc]
+ enum: [regular]
direction:
type: string
enum: [offramp, onramp]
@@ -1719,6 +1763,8 @@ paths:
Path order does **not** imply trade direction for token/fiat pairs; omitting **`side`** returns **both** sides when available.
+ **Provider queue:** each side carries **`providerIds`** — up to 3 provider ids ranked best first, in the order assignment would try them. Pass them to **`destination.providerIds`** on `POST /sender/orders` to lock routing to the providers you quoted. Use **`limit`** to request fewer.
+
For the legacy single-number response, use **`GET /v1/rates/{token}/{amount}/{fiat}`** with optional `network` query.
servers:
- url: https://api.paycrest.io/v2
@@ -1771,7 +1817,15 @@ paths:
schema:
type: string
pattern: '^[A-Za-z]{8}$'
- description: Optional. When set, limits the quote to this provider. Must be exactly 8 alphabetic characters (A–Z, a–z).
+ description: Optional. When set, limits the quote to this provider. Must be exactly 8 alphabetic characters (A–Z, a–z). Returns a single entry in `providerIds`.
+ - in: query
+ name: limit
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 3
+ default: 3
+ description: Maximum number of ranked provider ids to return in `providerIds`, per side. Values outside 1–3 return **400**.
responses:
'200':
description: Token rate quote(s)
@@ -1785,7 +1839,7 @@ paths:
data:
$ref: '#/components/schemas/V2RateQuoteResponse'
'400':
- description: Bad request (invalid amount, side, provider_id, ambiguous from/to without from_source/to_source, unsupported asset/network, etc.)
+ description: Bad request (invalid amount, side, provider_id, limit outside 1–3, ambiguous from/to without from_source/to_source, unsupported asset/network, etc.)
'404':
description: No provider available for the requested swap
'500':
@@ -1993,6 +2047,19 @@ paths:
The response `providerAccount` contains a `receiveAddress` to send tokens to.
- **Onramp** (fiat → stablecoin): set `source.type = "fiat"` and `destination.type = "crypto"`.
The response `providerAccount` contains a virtual bank account for the user to deposit fiat into.
+
+ **Provider routing.** On **offramp**, leave `destination.providerIds` unset and the API resolves a ranked
+ queue of up to 3 providers at creation; assignment walks it in order. Set `destination.providerIds` to
+ supply your own queue — typically the ids from a [rate quote](/api-reference/general/get-token-rate) — or a
+ one-entry queue to route to a single provider. Queue exhaustion is not an immediate refund: fallback
+ assignment runs first, then the refund window.
+
+ On **onramp**, the provider is selected when the order is created, so `destination.providerIds` accepts at
+ most one entry and acts as a pin.
+
+ The singular `destination.providerId` is retained for backward compatibility on both flows and is mutually
+ exclusive with `providerIds`. Routing validation failures return **400** with `data` as a single
+ `{ field, message }` object rather than an array.
security:
- ApiKeyAuth: []
servers:
@@ -2021,6 +2088,24 @@ paths:
accountIdentifier: "1234567890"
accountName: John Doe
memo: Payment
+ offrampWithQueue:
+ summary: Offramp with an explicit provider queue from a rate quote
+ value:
+ amount: "100"
+ source:
+ type: crypto
+ currency: USDT
+ network: base
+ refundAddress: "0xYourAddress"
+ destination:
+ type: fiat
+ currency: NGN
+ providerIds: [AbCdEfGh, IjKlMnOp, QrStUvWx]
+ recipient:
+ institution: GTBINGLA
+ accountIdentifier: "1234567890"
+ accountName: John Doe
+ memo: Payment
onramp:
summary: Onramp — NGN → USDT on Base
value:
diff --git a/resources/changelog.mdx b/resources/changelog.mdx
index ee4dd35..acbe50c 100644
--- a/resources/changelog.mdx
+++ b/resources/changelog.mdx
@@ -9,6 +9,42 @@ This page tracks significant changes to the Paycrest API and protocol. For full
## Q3 2026
+### Optimistic provider routing (August 2026)
+
+Order routing is now **optimistic**: a ranked queue of up to **3 providers** is resolved once, at order creation, and assignment walks it in order instead of re-ranking the public orderbook on every retry. The fulfilling provider is known upfront and routing no longer drifts between creation and fulfilment.
+
+**Updated:** `GET /v2/rates/{network}/{from}/{amount}/{to}` — each side's **`providerIds`** now carries up to **3 provider ids ranked best first**, in the order assignment would try them. Previously always 0 or 1 entry. `providerIds[0]` is still the provider whose rate is in `rate`, so clients reading `[0]` are unaffected.
+
+| Param | Form | Behavior |
+|-------|------|----------|
+| `limit` | single | Max ranked ids per side. Integer **1–3**, default **3**. Anything else returns **400** |
+
+Pinned (`?provider_id=`) quotes still return exactly one id. The fallback provider is never listed.
+
+**New:** order creation accepts an explicit **`providerIds`** queue — `destination.providerIds` on `POST /v2/sender/orders`, `recipient.providerIds` on `POST /v1/sender/orders`, and `providerIds` inside the encrypted recipient for onchain orders. Pass the ids from a rate quote to route the order to the providers your user was quoted.
+
+| Rule | Behavior |
+|------|----------|
+| Mutually exclusive with `providerId` | Sending both returns **400** |
+| Size | Offramp up to **3** entries; onramp **1** (it selects its provider at creation rather than walking a queue) |
+| No duplicates, no empty strings | **400** |
+| Format | Exactly 8 alphabetic characters per id |
+| Eligibility | Multi-entry queues must name active providers with a live standard offer for the corridor; a provider restricted to specific senders is allowed when you are on its list. The fallback provider may be listed and is ignored |
+| One-entry queues | Exactly a pin — `providerIds: [X]` routes like `providerId: X` for every provider you can pin |
+| Response | Accepted on create, never echoed back. Orders and webhooks return the singular assigned `providerId` |
+
+The singular **`providerId`** is retained for backward compatibility on both flows; `providerIds` covers every case it does, so new integrations need only the array form.
+
+**Behavior change:** the recipient encryption size check now budgets for a **full 3-provider queue on every order** — including orders that send no `providerIds` — because a queue may be resolved onto the order and rides in the message hash at gateway submit. The effective budget for `accountIdentifier` + `accountName` + `memo` + `metadata` is **~49 bytes smaller** within the 500-byte cap (**~41** when pinning a `providerId`). Requests that previously passed can now return **400** `"Recipient data too large for encryption"`. Trim `memo` and `metadata` if you were near the limit.
+
+**Behavior change:** pinning an **onramp** order to a provider restricted to other senders now returns **400** `provider X cannot serve this order`. Offramp has always enforced this; onramp previously accepted such a pin and routed the order. It applies to **`providerId`** as well as `providerIds`, so onramp integrations that pin providers should confirm they are authorized for them.
+
+**Exhausting a queue is not an immediate refund** — fallback assignment runs after the last entry, then refund only after the refund window.
+
+See [Get Token Rate](/api-reference/general/get-token-rate), [Pin a provider queue](/implementation-guides/sender-api-integration#pin-a-provider-queue), and [Smart Contract Interaction](/implementation-guides/smart-contract-interaction#routing-fields).
+
+---
+
### Optional `senderFeeAddress` on V2 create order (July 2026)
**Updated:** `POST /v2/sender/orders` accepts an optional top-level **`senderFeeAddress`**.
diff --git a/resources/troubleshooting.mdx b/resources/troubleshooting.mdx
index 8707f24..958c769 100644
--- a/resources/troubleshooting.mdx
+++ b/resources/troubleshooting.mdx
@@ -36,6 +36,15 @@ This guide helps you resolve common issues when integrating with the Paycrest AP
Invalid Tron address: For `network: "tron"`, use a Base58 address starting with `T`, not an EVM `0x` address
Missing recipient details: All recipient fields are required
Invalid institution: Check supported institutions for the currency
+ Provider routing rejected: providerIds and providerId are mutually exclusive; offramp queues cap at 3 entries and onramp at 1; entries must name providers you can route to. See Pin a provider queue
+
+
+ "Recipient data too large for encryption"
+ The recipient object is capped at 500 bytes of JSON before encryption. The size check reserves room for a full 3-provider routing queue on every order — including orders that send no providerIds — because the aggregator may resolve one onto the order and it rides in the message hash at gateway submit.
+
+ - That reservation costs about 49 bytes (~41 when you pin a
providerId), leaving roughly 450 bytes for accountIdentifier + accountName + memo + metadata combined
+ - Trim
memo first, then metadata — keep only apiKey and corridor-required keys like channel
+ accountName from verify-account is often longer than what you supplied; count the resolved value
Example Error Response