From e7b0a49115694f80326538365fbbce34bcd553a9 Mon Sep 17 00:00:00 2001 From: Chibuotu Amadi Date: Wed, 12 Aug 2026 00:37:25 +0100 Subject: [PATCH 1/5] docs(routing): document optimistic provider queue (#1013) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the ranked provider queue resolved at order creation (paycrest/aggregator#1013). - GET /v2/rates: new `limit` query param (1-3, default 3); `providerIds` now returns up to 3 ranked ids per side instead of 0 or 1 - Order creation: `destination.providerIds` (v2 offramp) and `recipient.providerIds` (v1), marked writeOnly since neither is echoed back in responses or webhooks; full validation-error table - Onchain: `providerIds` in the encrypted recipient, plus a warning that the 500-byte message-hash budget is now ~49 bytes tighter on every order — payloads that previously passed can start failing - Notes throughout that queue exhaustion falls through to fallback assignment, not an immediate refund v1 rates left untouched: a scalar `data` cannot carry a ranked list. Co-Authored-By: Claude Opus 5 --- api-reference/errors.mdx | 3 + api-reference/general/get-token-rate.mdx | 37 ++++++++-- .../sender-api-integration.mdx | 56 ++++++++++++++++ .../smart-contract-interaction.mdx | 27 ++++++++ openapi-v1.yaml | 13 ++++ openapi-v2.yaml | 67 +++++++++++++++++-- resources/changelog.mdx | 31 +++++++++ resources/troubleshooting.mdx | 10 +++ 8 files changed, 235 insertions(+), 9 deletions(-) diff --git a/api-reference/errors.mdx b/api-reference/errors.mdx index d2e22c7..c58d1b8 100644 --- a/api-reference/errors.mdx +++ b/api-reference/errors.mdx @@ -40,12 +40,15 @@ All error responses follow a consistent format: - **Unsupported combinations**: Token/currency pairs not supported - **Amount validation**: Amounts outside provider limits - **Provider issues**: Provider not found or misconfigured +- **Provider queue**: `providerId` and `providerIds` sent together, more than 3 entries, duplicates, or an entry that can't serve the corridor — see [Pin a provider queue](/implementation-guides/sender-api-integration#pin-a-provider-queue) ### 503 Service Unavailable - **No provider available**: When no providers can handle the specific request - **Service temporarily unavailable**: During maintenance or high load - **Geographic restrictions**: Service not available in your region +A 503 means no provider could be found **at create time**. Once an order is created it is never 503'd: routing walks the order's provider queue, and exhausting that queue falls through to fallback assignment, then to refund after the refund window — not to an immediate refund. + ## Handling Service Unavailability When you receive a 503 error: diff --git a/api-reference/general/get-token-rate.mdx b/api-reference/general/get-token-rate.mdx index 4a76b1d..dc51107 100644 --- a/api-reference/general/get-token-rate.mdx +++ b/api-reference/general/get-token-rate.mdx @@ -29,7 +29,8 @@ Get buy and/or sell quotes on a **specific network**—either a **token + fiat** | `side` | No | `buy` or `sell` to return **only** that side. Omit to receive **both** when available. | | `from_source` | No | If `from` matches **both** a fiat currency and a crypto asset on the network, set to **`fiat`** or **`crypto`**. Omit when unambiguous. | | `to_source` | No | If `to` matches **both** a fiat currency and a crypto asset on the network, set to **`fiat`** or **`crypto`**. Omit when unambiguous. | -| `provider_id` | No | Exactly **8 alphabetic** characters (`A–Z`, `a–z`) to pin the quote to one provider. | +| `provider_id` | No | Exactly **8 alphabetic** characters (`A–Z`, `a–z`) to pin the quote to one provider. Returns a **single** entry in `providerIds`. | +| `limit` | No | Max ranked provider ids per side in `providerIds`. Integer **1–3**, default **3**. Anything else returns **400**. | ## Example @@ -45,6 +46,9 @@ GET https://api.paycrest.io/v2/rates/base/USDT/100/NGN?side=sell # Specific provider GET https://api.paycrest.io/v2/rates/base/USDT/100/NGN?provider_id=AbCdEfGh + +# Best provider only (single id in providerIds) +GET https://api.paycrest.io/v2/rates/base/USDT/100/NGN?limit=1 ``` ## Success response (`data`) @@ -53,8 +57,8 @@ Each populated side includes: | Field | Type | Description | |-------|------|-------------| -| `rate` | string | For token+fiat: fiat per crypto. For fiat+fiat: see path rules above (`sell` = `to` per `from`, `buy` = `from` per `to`). | -| `providerIds` | string[] | Provider id(s) for the quote | +| `rate` | string | For token+fiat: fiat per crypto. For fiat+fiat: see path rules above (`sell` = `to` per `from`, `buy` = `from` per `to`). This is the rate of **`providerIds[0]`**. | +| `providerIds` | string[] | **Ranked provider queue**, best first—up to **3** ids, in the order assignment would try them. See [Rate resolution](#rate-resolution). | | `orderType` | string | e.g. `regular` or `otc` | | `refundTimeoutMinutes` | integer | Minutes until automatic refund for that flow | @@ -71,7 +75,7 @@ Each populated side includes: }, "sell": { "rate": "1500.50", - "providerIds": ["AbCdEfGh"], + "providerIds": ["AbCdEfGh", "IjKlMnOp", "QrStUvWx"], "orderType": "regular", "refundTimeoutMinutes": 60 } @@ -81,11 +85,13 @@ Each populated side includes: When `side=buy` or `side=sell`, only that key is present under `data`. +**`providerIds` is always an array**—`[]` when no provider is available, never `null`. It carries **exactly one** id when you pass `?provider_id=`, when `?limit=1`, or when `orderType` is **`otc`**. Fewer than 3 ids simply means fewer eligible providers for that corridor and notional. The **fallback provider is never listed**. + ## Errors | HTTP | Typical cause | |------|----------------| -| **400** | Invalid `amount`, invalid `side`, invalid `from_source`/`to_source` (must be `fiat` or `crypto`), ambiguous segment without disambiguation, invalid `provider_id`, unsupported asset on network, or conversion rules (e.g. stable only to its base fiat) | +| **400** | Invalid `amount`, invalid `side`, invalid `from_source`/`to_source` (must be `fiat` or `crypto`), ambiguous segment without disambiguation, invalid `provider_id`, `limit` outside **1–3**, unsupported asset on network, or conversion rules (e.g. stable only to its base fiat) | | **404** | No provider available for the amount/currency/network | | **503** | Banking/mobile network issues affecting providers for the currency | | **500** | Internal error while resolving the rate | @@ -94,4 +100,25 @@ When `side=buy` or `side=sell`, only that key is present under `data`. Behavior matches the priority-queue–based resolution used when creating orders: the quote reflects provider selection and validation for the requested **side** (`buy` vs `sell`). **Path segment order (`from` / `to`) does not select direction**—use `?side=`, or read **`data.sell`** for **offramp** (crypto → fiat) display and **`data.buy`** for **onramp** (fiat → crypto) before `POST /v2/sender/orders`. +### The provider queue + +**`providerIds` is ranked, best first**, and is the same queue an order created from this quote will walk. **`providerIds[0]`** is the provider whose rate is quoted in **`rate`**; the remaining ids are the ones assignment falls through to if the head declines, expires, or exhausts its retries. + +Routing is **optimistic**: the queue is resolved **once, at order creation**, rather than re-ranked on every retry. Two ways to use that: + + + + Omit **`destination.providerId`** and **`destination.providerIds`** on create. The API resolves a fresh queue at creation time. Nothing to do—this is the default. + + + Send **`data.sell.providerIds`** straight through as **`destination.providerIds`** on `POST /v2/sender/orders`. Routing is then pinned to the providers your user actually saw a rate from. See [Pin a provider queue](/implementation-guides/sender-api-integration#pin-a-provider-queue). + + + + + **Exhausting the queue is not an immediate refund.** After the last entry, the order falls through to **fallback assignment**, and only then to refund after the refund window and its guards. Don't model this as "3 tries then refund". + + +Queues are built for **open, standard-tier** providers only. Pinned, restricted, and OTC quotes return a single id and keep the pre-existing single-provider behavior. + See also [Get Token Rate (v1)](/api-reference/general/get-token-rate-v1) for the legacy scalar response. diff --git a/implementation-guides/sender-api-integration.mdx b/implementation-guides/sender-api-integration.mdx index 2b3b63a..563f523 100644 --- a/implementation-guides/sender-api-integration.mdx +++ b/implementation-guides/sender-api-integration.mdx @@ -288,6 +288,54 @@ order = requests.post( | `source` | object | ✅ | `{ type: "crypto", ... }` for offramp; `{ type: "fiat", ... }` for onramp. | | `destination` | object | ✅ | `{ type: "fiat", ... }` for offramp; `{ type: "crypto", ... }` for onramp. | +### Pin a provider queue + +By default the API resolves a **ranked queue of up to 3 providers** at order creation and assignment walks it in order—if the first declines, expires, or exhausts its retries, the order moves to the next. You don't have to do anything to get this. + +Set **`destination.providerIds`** when you want routing pinned to providers you already quoted. Typically you pass through the ids from [`GET /v2/rates`](/api-reference/general/get-token-rate) so the user is routed to the provider whose rate they saw: + +```json +"destination": { + "type": "fiat", + "currency": "NGN", + "providerIds": ["AbCdEfGh", "IjKlMnOp", "QrStUvWx"], + "recipient": { + "institution": "GTBINGLA", + "accountIdentifier": "1234567890", + "accountName": "John Doe", + "memo": "Payment" + } +} +``` + +| Rule | Detail | +|------|--------| +| Order matters | Entries are tried first to last. `providerIds[0]` should be the provider whose rate you locked in `rate`. | +| Max **3** | More than 3 entries returns **400**. | +| Format | Each id is exactly **8 alphabetic** characters (`A–Z`, `a–z`). | +| Mutually exclusive with `providerId` | Sending **both** returns **400**. Use `providerIds` for a queue, `providerId` to pin exactly one. | +| No duplicates, no empty strings | Both return **400**. | +| Offramp only | `destination.providerIds` applies to `destination.type: "fiat"`. Onramp supports only the singular `providerId`. | +| Eligible providers only | Entries must be **open, standard-tier** providers serving this corridor. **OTC**, **restricted**, and **fallback** providers are rejected—pin those with `providerId` instead. | + +Validation failures return **400** with `message: "Failed to validate payload"` and `data` as a single object—`{ "field": "Destination", "message": "..." }`—not an array: + +| `data.message` | Cause | +|----------------|-------| +| `providerId and providerIds are mutually exclusive; provide only one` | Both fields sent | +| `providerIds accepts at most 3 providers` | More than 3 entries | +| `Invalid provider ID "xyz". Expected 8 alphabetic characters` | Malformed entry | +| `providerIds contains an empty provider ID` | Empty-string entry | +| `providerIds contains duplicate provider ID: AbCdEfGh` | Repeated entry | +| `provider AbCdEfGh cannot be queued explicitly` | Entry is the fallback provider | +| `provider AbCdEfGh cannot serve this order` | Entry is inactive, restricted, OTC-only, or has no live offer for this corridor | + + + **Exhausting the queue does not refund immediately.** After the last entry, the order falls through to **fallback assignment**, and only then to refund after the refund window. `providerIds` is accepted on create but **not echoed back**—order responses and webhooks return the singular assigned **`providerId`**. + + +On the **v1** API the same field lives at **`recipient.providerIds`** with identical rules; its errors use `"field": "Recipient"`. + --- ## KES mobile money (M-Pesa, Till, Paybill) @@ -628,6 +676,8 @@ A `200 OK` response returns the resolved name in `data`. If `data` is a real nam The JSON `data` object includes **`buy`** and **`sell`** (unless you pass `?side=buy` or `?side=sell`). Use **`data.sell.rate`** for **offramp** and **`data.buy.rate`** for **onramp** when displaying a quote. **`provider_id`** is optional and must be exactly **8 letters** (`A–Z` or `a–z`) if you pin a provider. + + Each side also carries **`providerIds`**—up to **3** ranked provider ids, best first. Pass them to **`destination.providerIds`** on create to route the order to the providers you quoted (see [Pin a provider queue](#pin-a-provider-queue)). Add **`?limit=1|2|3`** to request fewer. @@ -641,6 +691,9 @@ curl "https://api.paycrest.io/v2/rates/base/USDT/100/NGN" const res = await fetch("https://api.paycrest.io/v2/rates/base/USDT/100/NGN").then((r) => r.json()); const sellRate = res.data?.sell?.rate; // offramp display const buyRate = res.data?.buy?.rate; // onramp display + +// Ranked provider queue — pass straight through on create as destination.providerIds +const providerIds = res.data?.sell?.providerIds ?? []; ``` @@ -650,6 +703,9 @@ res = requests.get("https://api.paycrest.io/v2/rates/base/USDT/100/NGN") data = res.json()["data"] sell_rate = (data.get("sell") or {}).get("rate") buy_rate = (data.get("buy") or {}).get("rate") + +# Ranked provider queue — pass straight through on create as destination.providerIds +provider_ids = (data.get("sell") or {}).get("providerIds") or [] ``` diff --git a/implementation-guides/smart-contract-interaction.mdx b/implementation-guides/smart-contract-interaction.mdx index d4116e8..ef96989 100644 --- a/implementation-guides/smart-contract-interaction.mdx +++ b/implementation-guides/smart-contract-interaction.mdx @@ -323,6 +323,8 @@ func createOffRampOrder(amount string, recipient map[string]interface{}, refundA For **mobile money** recipients, you may pass local MSISDN (e.g. KES `07…`); **`POST /v2/verify-account`** normalizes dial codes server-side. Optional **`metadata`** on verify applies for KES Till/Paybill. See [Verify Account](/api-reference/general/verify-account). +The same rates response also returns **`data.sell.providerIds`**—the ranked provider queue for the corridor. Read it alongside `rate` if you want to carry that queue into the encrypted recipient as `providerIds` (see [Routing fields](#routing-fields)). + ```javascript @@ -472,6 +474,31 @@ The recipient object is encrypted with the aggregator's public key to produce th Your API Key is available in the dashboard at [app.paycrest.io](https://app.paycrest.io). +### Routing fields + +The recipient object may carry routing hints—this is the only channel for them onchain, since `createOrder` has no provider argument: + +| Field | Description | +|-------|-------------| +| `providerId` | Pin the order to exactly one provider. 8 alphabetic characters. | +| `providerIds` | Ordered **provider queue** (max 3). Assignment tries entries in order. Typically the ids from [`GET /v2/rates`](/api-reference/general/get-token-rate). | + +The two are **mutually exclusive**. A hash carrying both is not rejected—the queue wins and `providerId` is ignored. A queue whose entries can't serve the corridor is **dropped silently** and the order routes as unpinned; there is no onchain rejection and no error webhook, so validate ids against a rate quote before encrypting. + +Omit both and the aggregator resolves a queue itself at order creation. Entries must be **open, standard-tier** providers—OTC, restricted, and fallback providers must use the singular `providerId`. + + + **The encrypted payload budget shrank.** `MESSAGE_HASH_MAX_SIZE` is **500 bytes**, applied to the recipient JSON **before** encryption. The size check now budgets for a **full 3-provider 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. + + Effective budget for `accountIdentifier` + `accountName` + `memo` + `metadata` is therefore **~49 bytes smaller** than before (**~41** if you pin a `providerId`). Payloads that previously passed can now fail with **400** and `"Recipient data too large for encryption"`. Trim `memo` and `metadata` first—see [Troubleshooting](/resources/troubleshooting). + + The check runs in **production and staging only**, so an oversized payload will not reproduce against a local aggregator. + + + + **Reading a hash the aggregator produced:** queued orders publish `"ProviderID": ""` plus a `ProviderIDs` array. Anything downstream that parses `ProviderID` must fall back to `ProviderIDs[0]`. Keys decode case-insensitively, so `providerIds` and `ProviderIDs` are equivalent on the way in. + + ```javascript diff --git a/openapi-v1.yaml b/openapi-v1.yaml index e0ed543..fbc1fa1 100644 --- a/openapi-v1.yaml +++ b/openapi-v1.yaml @@ -69,6 +69,19 @@ components: providerId: type: string description: Optional provider ID for specific routing + 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**. Entries must be open, standard-tier providers serving this corridor; + OTC, restricted, and fallback providers are rejected — pin those with `providerId` instead. + 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 4831fb8..2cefdea 100644 --- a/openapi-v2.yaml +++ b/openapi-v2.yaml @@ -525,12 +525,19 @@ 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 or when `orderType` + is `otc`. 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`. @@ -848,6 +855,21 @@ components: providerId: type: string description: Pin order to a specific provider (optional) + 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**. + Entries must be open, standard-tier providers serving this corridor; OTC, restricted, and + fallback providers are rejected — pin those with `providerId` instead. + 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. @@ -896,6 +918,7 @@ components: description: Stablecoin symbol (e.g. USDT, USDC) providerId: type: string + description: Pin order to a specific provider (optional). Onramp does not support the `providerIds` queue. recipient: $ref: '#/components/schemas/V2CryptoRecipient' @@ -1709,6 +1732,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 @@ -1761,7 +1786,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) @@ -1775,7 +1808,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': @@ -1983,6 +2016,14 @@ 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 (offramp).** Leave `destination.providerId` and `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 `destination.providerId` to pin one provider. + The two are mutually exclusive. Queue exhaustion is not an immediate refund — fallback assignment runs + first, then the refund window. Provider-queue validation failures return **400** with `data` as a single + `{ field, message }` object rather than an array. security: - ApiKeyAuth: [] servers: @@ -2011,6 +2052,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 0aaa4d9..e8d5b80 100644 --- a/resources/changelog.mdx +++ b/resources/changelog.mdx @@ -9,6 +9,37 @@ 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=`) and **OTC** 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` (offramp), `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** | +| Max 3 entries, no duplicates, no empty strings | **400** | +| Format | Exactly 8 alphabetic characters per id | +| Eligibility | Open, standard-tier providers serving the corridor. **OTC**, restricted, and fallback providers are rejected — pin those with `providerId` | +| Scope | Offramp only. Onramp keeps the singular `providerId` | +| Response | Accepted on create, never echoed back. Orders and webhooks return the singular assigned `providerId` | + +**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. + +**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). + +--- + ### Markets orderbook query filters (July 2026) **Updated:** `GET /v2/markets` accepts optional query filters to narrow the returned orderbook: diff --git a/resources/troubleshooting.mdx b/resources/troubleshooting.mdx index b35126a..ba3e53b 100644 --- a/resources/troubleshooting.mdx +++ b/resources/troubleshooting.mdx @@ -34,6 +34,16 @@ This guide helps you resolve common issues when integrating with the Paycrest AP
  • Invalid network: Ensure the network supports your chosen token
  • Missing recipient details: All recipient fields are required
  • Invalid institution: Check supported institutions for the currency
  • +
  • Provider queue rejected: providerIds and providerId are mutually exclusive, capped at 3 entries, and must name open standard-tier providers serving the corridor. 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 budgets 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.

    +
      +
    • Effective budget is ~49 bytes smaller than it was before optimistic routing (~41 when you pin a providerId). Payloads that used to pass can now fail
    • +
    • 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
    • +
    • The check runs in production and staging only, so it will not reproduce against a local aggregator

    Example Error Response

    From bca742bdca159d42ac50ed8c20626a07f9a498b5 Mon Sep 17 00:00:00 2001 From: Chibuotu Amadi Date: Wed, 12 Aug 2026 01:14:36 +0100 Subject: [PATCH 2/5] docs: drop OTC references and before/after framing Removes every mention of OTC across the docs, and rewrites the provider queue prose to describe current behavior directly instead of contrasting it with how routing used to work. The changelog keeps its before/after framing, which is the point of that page. Notable: `orderType` enums in openapi-v2.yaml drop `otc`, leaving `enum: [regular]`. Co-Authored-By: Claude Opus 5 --- api-reference/general/get-token-rate.mdx | 8 ++++---- concepts/participants.mdx | 1 - implementation-guides/sender-api-integration.mdx | 4 ++-- .../smart-contract-interaction.mdx | 6 +++--- openapi-v1.yaml | 2 +- openapi-v2.yaml | 15 +++++++-------- resources/changelog.mdx | 4 ++-- resources/troubleshooting.mdx | 4 ++-- 8 files changed, 21 insertions(+), 23 deletions(-) diff --git a/api-reference/general/get-token-rate.mdx b/api-reference/general/get-token-rate.mdx index 2e036ca..e9cfc30 100644 --- a/api-reference/general/get-token-rate.mdx +++ b/api-reference/general/get-token-rate.mdx @@ -59,7 +59,7 @@ Each populated side includes: |-------|------|-------------| | `rate` | string | For token+fiat: fiat per crypto. For fiat+fiat: see path rules above (`sell` = `to` per `from`, `buy` = `from` per `to`). This is the rate of **`providerIds[0]`**. | | `providerIds` | string[] | **Ranked provider queue**, best first—up to **3** ids, in the order assignment would try them. See [Rate resolution](#rate-resolution). | -| `orderType` | string | e.g. `regular` or `otc` | +| `orderType` | string | e.g. `regular` | | `refundTimeoutMinutes` | integer | Minutes until automatic refund for that flow | ```json @@ -85,7 +85,7 @@ Each populated side includes: When `side=buy` or `side=sell`, only that key is present under `data`. -**`providerIds` is always an array**—`[]` when no provider is available, never `null`. It carries **exactly one** id when you pass `?provider_id=`, when `?limit=1`, or when `orderType` is **`otc`**. Fewer than 3 ids simply means fewer eligible providers for that corridor and notional. The **fallback provider is never listed**. +**`providerIds` is always an array**—`[]` when no provider is available, never `null`. It carries **exactly one** id when you pass `?provider_id=` or `?limit=1`. Fewer than 3 ids means fewer eligible providers for that corridor and notional. The **fallback provider is never listed**. ## Errors @@ -104,7 +104,7 @@ Behavior matches the priority-queue–based resolution used when creating orders **`providerIds` is ranked, best first**, and is the same queue an order created from this quote will walk. **`providerIds[0]`** is the provider whose rate is quoted in **`rate`**; the remaining ids are the ones assignment falls through to if the head declines, expires, or exhausts its retries. -Routing is **optimistic**: the queue is resolved **once, at order creation**, rather than re-ranked on every retry. Two ways to use that: +The queue is resolved **once, at order creation**, and the order carries it for its whole life. Two ways to use that: @@ -119,6 +119,6 @@ Routing is **optimistic**: the queue is resolved **once, at order creation**, ra **Exhausting the queue is not an immediate refund.** After the last entry, the order falls through to **fallback assignment**, and only then to refund after the refund window and its guards. Don't model this as "3 tries then refund". -Queues are built for **open, standard-tier** providers only. Pinned, restricted, and OTC quotes return a single id and keep the pre-existing single-provider behavior. +Queues are built for **open, standard-tier** providers only. Pinned and restricted quotes return a single id, and the order routes to that one provider. See also [Get Token Rate (v1)](/api-reference/general/get-token-rate-v1) for the legacy scalar response. diff --git a/concepts/participants.mdx b/concepts/participants.mdx index c857810..7addf1d 100644 --- a/concepts/participants.mdx +++ b/concepts/participants.mdx @@ -98,7 +98,6 @@ A **KYB-verified participant** that supplies fiat liquidity in exchange for stab - Crypto exchanges - DeFi protocols - - OTC desks - Crypto payment processors diff --git a/implementation-guides/sender-api-integration.mdx b/implementation-guides/sender-api-integration.mdx index 72be7e8..6e1e263 100644 --- a/implementation-guides/sender-api-integration.mdx +++ b/implementation-guides/sender-api-integration.mdx @@ -386,7 +386,7 @@ Set **`destination.providerIds`** when you want routing pinned to providers you | Mutually exclusive with `providerId` | Sending **both** returns **400**. Use `providerIds` for a queue, `providerId` to pin exactly one. | | No duplicates, no empty strings | Both return **400**. | | Offramp only | `destination.providerIds` applies to `destination.type: "fiat"`. Onramp supports only the singular `providerId`. | -| Eligible providers only | Entries must be **open, standard-tier** providers serving this corridor. **OTC**, **restricted**, and **fallback** providers are rejected—pin those with `providerId` instead. | +| Eligible providers only | Entries must be **open, standard-tier** providers serving this corridor. **Restricted** and **fallback** providers are rejected—pin those with `providerId` instead. | Validation failures return **400** with `message: "Failed to validate payload"` and `data` as a single object—`{ "field": "Destination", "message": "..." }`—not an array: @@ -398,7 +398,7 @@ Validation failures return **400** with `message: "Failed to validate payload"` | `providerIds contains an empty provider ID` | Empty-string entry | | `providerIds contains duplicate provider ID: AbCdEfGh` | Repeated entry | | `provider AbCdEfGh cannot be queued explicitly` | Entry is the fallback provider | -| `provider AbCdEfGh cannot serve this order` | Entry is inactive, restricted, OTC-only, or has no live offer for this corridor | +| `provider AbCdEfGh cannot serve this order` | Entry is inactive, restricted, or has no live offer for this corridor | **Exhausting the queue does not refund immediately.** After the last entry, the order falls through to **fallback assignment**, and only then to refund after the refund window. `providerIds` is accepted on create but **not echoed back**—order responses and webhooks return the singular assigned **`providerId`**. diff --git a/implementation-guides/smart-contract-interaction.mdx b/implementation-guides/smart-contract-interaction.mdx index f7d0e2e..f6da93c 100644 --- a/implementation-guides/smart-contract-interaction.mdx +++ b/implementation-guides/smart-contract-interaction.mdx @@ -489,12 +489,12 @@ The recipient object may carry routing hints—this is the only channel for them The two are **mutually exclusive**. A hash carrying both is not rejected—the queue wins and `providerId` is ignored. A queue whose entries can't serve the corridor is **dropped silently** and the order routes as unpinned; there is no onchain rejection and no error webhook, so validate ids against a rate quote before encrypting. -Omit both and the aggregator resolves a queue itself at order creation. Entries must be **open, standard-tier** providers—OTC, restricted, and fallback providers must use the singular `providerId`. +Omit both and the aggregator resolves a queue itself at order creation. Entries must be **open, standard-tier** providers—restricted and fallback providers must use the singular `providerId`. - **The encrypted payload budget shrank.** `MESSAGE_HASH_MAX_SIZE` is **500 bytes**, applied to the recipient JSON **before** encryption. The size check now budgets for a **full 3-provider 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. + **Budget ~450 bytes for recipient data.** `MESSAGE_HASH_MAX_SIZE` caps the recipient JSON at **500 bytes**, measured **before** encryption. The size check reserves room for a full 3-provider 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. - Effective budget for `accountIdentifier` + `accountName` + `memo` + `metadata` is therefore **~49 bytes smaller** than before (**~41** if you pin a `providerId`). Payloads that previously passed can now fail with **400** and `"Recipient data too large for encryption"`. Trim `memo` and `metadata` first—see [Troubleshooting](/resources/troubleshooting). + That reservation costs about **49 bytes** (**~41** if you pin a `providerId`), leaving roughly **450 bytes** for `accountIdentifier` + `accountName` + `memo` + `metadata` combined. Exceeding it returns **400** with `"Recipient data too large for encryption"`. Trim `memo` and `metadata` first—see [Troubleshooting](/resources/troubleshooting). The check runs in **production and staging only**, so an oversized payload will not reproduce against a local aggregator. diff --git a/openapi-v1.yaml b/openapi-v1.yaml index dbaf7a1..a2d14da 100644 --- a/openapi-v1.yaml +++ b/openapi-v1.yaml @@ -80,7 +80,7 @@ components: 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**. Entries must be open, standard-tier providers serving this corridor; - OTC, restricted, and fallback providers are rejected — pin those with `providerId` instead. + restricted and fallback providers are rejected — pin those with `providerId` instead. Accepted on create only; order responses and webhooks return the singular assigned `providerId`. metadata: type: object diff --git a/openapi-v2.yaml b/openapi-v2.yaml index dda548d..08bc49b 100644 --- a/openapi-v2.yaml +++ b/openapi-v2.yaml @@ -534,13 +534,12 @@ components: 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 or when `orderType` - is `otc`. 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. + 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. @@ -877,7 +876,7 @@ components: 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**. - Entries must be open, standard-tier providers serving this corridor; OTC, restricted, and + Entries must be open, standard-tier providers serving this corridor; restricted and fallback providers are rejected — pin those with `providerId` instead. Accepted on create only; order responses and webhooks return the singular assigned `providerId`. kyc: @@ -991,7 +990,7 @@ components: type: string orderType: type: string - enum: [regular, otc] + enum: [regular] timestamp: type: string format: date-time @@ -1034,7 +1033,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] diff --git a/resources/changelog.mdx b/resources/changelog.mdx index 2888edf..089334f 100644 --- a/resources/changelog.mdx +++ b/resources/changelog.mdx @@ -19,7 +19,7 @@ Order routing is now **optimistic**: a ranked queue of up to **3 providers** is |-------|------|----------| | `limit` | single | Max ranked ids per side. Integer **1–3**, default **3**. Anything else returns **400** | -Pinned (`?provider_id=`) and **OTC** quotes still return exactly one id. The fallback provider is never listed. +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` (offramp), `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. @@ -28,7 +28,7 @@ Pinned (`?provider_id=`) and **OTC** quotes still return exactly one id. The fal | Mutually exclusive with `providerId` | Sending both returns **400** | | Max 3 entries, no duplicates, no empty strings | **400** | | Format | Exactly 8 alphabetic characters per id | -| Eligibility | Open, standard-tier providers serving the corridor. **OTC**, restricted, and fallback providers are rejected — pin those with `providerId` | +| Eligibility | Open, standard-tier providers serving the corridor. Restricted and fallback providers are rejected — pin those with `providerId` | | Scope | Offramp only. Onramp keeps the singular `providerId` | | Response | Accepted on create, never echoed back. Orders and webhooks return the singular assigned `providerId` | diff --git a/resources/troubleshooting.mdx b/resources/troubleshooting.mdx index f91ba9a..c938898 100644 --- a/resources/troubleshooting.mdx +++ b/resources/troubleshooting.mdx @@ -40,9 +40,9 @@ This guide helps you resolve common issues when integrating with the Paycrest AP

    "Recipient data too large for encryption"

    -

    The recipient object is capped at 500 bytes of JSON before encryption. The size check budgets 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.

    +

    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.

      -
    • Effective budget is ~49 bytes smaller than it was before optimistic routing (~41 when you pin a providerId). Payloads that used to pass can now fail
    • +
    • 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
    • The check runs in production and staging only, so it will not reproduce against a local aggregator
    • From 6e3687e17f992bee29b3f6676b50c097f37d5030 Mon Sep 17 00:00:00 2001 From: Chibuotu Amadi Date: Wed, 12 Aug 2026 01:28:28 +0100 Subject: [PATCH 3/5] docs: make providerIds the primary routing field Stops steering readers to providerId as the workaround for restricted and fallback providers, and reframes it as the backward-compatible single-provider form throughout. A one-entry providerIds is now the documented way to route to a single provider. The eligibility constraint is still stated (it is a real 400), just without pointing at providerId as the alternative. Co-Authored-By: Claude Opus 5 --- .../sender-api-integration.mdx | 4 ++-- .../smart-contract-interaction.mdx | 6 +++--- openapi-v1.yaml | 6 ++++-- openapi-v2.yaml | 20 ++++++++++--------- resources/changelog.mdx | 2 +- 5 files changed, 21 insertions(+), 17 deletions(-) diff --git a/implementation-guides/sender-api-integration.mdx b/implementation-guides/sender-api-integration.mdx index 6e1e263..31e3d8c 100644 --- a/implementation-guides/sender-api-integration.mdx +++ b/implementation-guides/sender-api-integration.mdx @@ -383,10 +383,10 @@ Set **`destination.providerIds`** when you want routing pinned to providers you | Order matters | Entries are tried first to last. `providerIds[0]` should be the provider whose rate you locked in `rate`. | | Max **3** | More than 3 entries returns **400**. | | Format | Each id is exactly **8 alphabetic** characters (`A–Z`, `a–z`). | -| Mutually exclusive with `providerId` | Sending **both** returns **400**. Use `providerIds` for a queue, `providerId` to pin exactly one. | +| Mutually exclusive with `providerId` | Sending **both** returns **400**. To route to a single provider, send a one-entry `providerIds`—the singular `providerId` is retained for backward compatibility. | | No duplicates, no empty strings | Both return **400**. | | Offramp only | `destination.providerIds` applies to `destination.type: "fiat"`. Onramp supports only the singular `providerId`. | -| Eligible providers only | Entries must be **open, standard-tier** providers serving this corridor. **Restricted** and **fallback** providers are rejected—pin those with `providerId` instead. | +| Eligible providers only | Entries must be **open, standard-tier** providers serving this corridor. **Restricted** and **fallback** providers are rejected. | Validation failures return **400** with `message: "Failed to validate payload"` and `data` as a single object—`{ "field": "Destination", "message": "..." }`—not an array: diff --git a/implementation-guides/smart-contract-interaction.mdx b/implementation-guides/smart-contract-interaction.mdx index f6da93c..fafb376 100644 --- a/implementation-guides/smart-contract-interaction.mdx +++ b/implementation-guides/smart-contract-interaction.mdx @@ -484,12 +484,12 @@ The recipient object may carry routing hints—this is the only channel for them | Field | Description | |-------|-------------| -| `providerId` | Pin the order to exactly one provider. 8 alphabetic characters. | -| `providerIds` | Ordered **provider queue** (max 3). Assignment tries entries in order. Typically the ids from [`GET /v2/rates`](/api-reference/general/get-token-rate). | +| `providerIds` | Ordered **provider queue** (max 3). Assignment tries entries in order. Typically the ids from [`GET /v2/rates`](/api-reference/general/get-token-rate). A one-entry queue routes to a single provider. | +| `providerId` | Single provider, 8 alphabetic characters. Retained for backward compatibility—prefer `providerIds`. | The two are **mutually exclusive**. A hash carrying both is not rejected—the queue wins and `providerId` is ignored. A queue whose entries can't serve the corridor is **dropped silently** and the order routes as unpinned; there is no onchain rejection and no error webhook, so validate ids against a rate quote before encrypting. -Omit both and the aggregator resolves a queue itself at order creation. Entries must be **open, standard-tier** providers—restricted and fallback providers must use the singular `providerId`. +Omit both and the aggregator resolves a queue itself at order creation. Entries must be **open, standard-tier** providers serving the corridor; restricted and fallback providers are rejected. **Budget ~450 bytes for recipient data.** `MESSAGE_HASH_MAX_SIZE` caps the recipient JSON at **500 bytes**, measured **before** encryption. The size check reserves room for a full 3-provider 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. diff --git a/openapi-v1.yaml b/openapi-v1.yaml index a2d14da..c5e9872 100644 --- a/openapi-v1.yaml +++ b/openapi-v1.yaml @@ -68,7 +68,9 @@ 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 @@ -80,7 +82,7 @@ components: 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**. Entries must be open, standard-tier providers serving this corridor; - restricted and fallback providers are rejected — pin those with `providerId` instead. + restricted and fallback providers are rejected. Accepted on create only; order responses and webhooks return the singular assigned `providerId`. metadata: type: object diff --git a/openapi-v2.yaml b/openapi-v2.yaml index 08bc49b..2f3ccf0 100644 --- a/openapi-v2.yaml +++ b/openapi-v2.yaml @@ -863,7 +863,9 @@ 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 @@ -877,7 +879,7 @@ components: [rate quote](/api-reference/general/get-token-rate). Mutually exclusive with `providerId` — sending both returns **400**. Entries must be open, standard-tier providers serving this corridor; restricted and - fallback providers are rejected — pin those with `providerId` instead. + fallback providers are rejected. Accepted on create only; order responses and webhooks return the singular assigned `providerId`. kyc: type: object @@ -2026,13 +2028,13 @@ paths: - **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 (offramp).** Leave `destination.providerId` and `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 `destination.providerId` to pin one provider. - The two are mutually exclusive. Queue exhaustion is not an immediate refund — fallback assignment runs - first, then the refund window. Provider-queue validation failures return **400** with `data` as a single - `{ field, message }` object rather than an array. + **Provider routing (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. The singular `destination.providerId` is retained for + backward compatibility and is mutually exclusive with `providerIds`. Queue exhaustion is not an immediate + refund — fallback assignment runs first, then the refund window. Provider-queue validation failures return + **400** with `data` as a single `{ field, message }` object rather than an array. security: - ApiKeyAuth: [] servers: diff --git a/resources/changelog.mdx b/resources/changelog.mdx index 089334f..a329db3 100644 --- a/resources/changelog.mdx +++ b/resources/changelog.mdx @@ -28,7 +28,7 @@ Pinned (`?provider_id=`) quotes still return exactly one id. The fallback provid | Mutually exclusive with `providerId` | Sending both returns **400** | | Max 3 entries, no duplicates, no empty strings | **400** | | Format | Exactly 8 alphabetic characters per id | -| Eligibility | Open, standard-tier providers serving the corridor. Restricted and fallback providers are rejected — pin those with `providerId` | +| Eligibility | Open, standard-tier providers serving the corridor. Restricted and fallback providers are rejected | | Scope | Offramp only. Onramp keeps the singular `providerId` | | Response | Accepted on create, never echoed back. Orders and webhooks return the singular assigned `providerId` | From b5516cd83644b4b1f58353ecde47ee899d648b48 Mon Sep 17 00:00:00 2001 From: Chibuotu Amadi Date: Wed, 12 Aug 2026 01:59:53 +0100 Subject: [PATCH 4/5] docs: drop environment references from the payload-size guidance The docs describe the production API, so naming which environments run the recipient size check is noise for the reader and an internal detail besides. The constraint itself is unchanged. --- implementation-guides/smart-contract-interaction.mdx | 2 -- resources/troubleshooting.mdx | 1 - 2 files changed, 3 deletions(-) diff --git a/implementation-guides/smart-contract-interaction.mdx b/implementation-guides/smart-contract-interaction.mdx index fafb376..5faf7e9 100644 --- a/implementation-guides/smart-contract-interaction.mdx +++ b/implementation-guides/smart-contract-interaction.mdx @@ -495,8 +495,6 @@ Omit both and the aggregator resolves a queue itself at order creation. Entries **Budget ~450 bytes for recipient data.** `MESSAGE_HASH_MAX_SIZE` caps the recipient JSON at **500 bytes**, measured **before** encryption. The size check reserves room for a full 3-provider 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** if you pin a `providerId`), leaving roughly **450 bytes** for `accountIdentifier` + `accountName` + `memo` + `metadata` combined. Exceeding it returns **400** with `"Recipient data too large for encryption"`. Trim `memo` and `metadata` first—see [Troubleshooting](/resources/troubleshooting). - - The check runs in **production and staging only**, so an oversized payload will not reproduce against a local aggregator. diff --git a/resources/troubleshooting.mdx b/resources/troubleshooting.mdx index c938898..6bc87be 100644 --- a/resources/troubleshooting.mdx +++ b/resources/troubleshooting.mdx @@ -45,7 +45,6 @@ This guide helps you resolve common issues when integrating with the Paycrest AP
    • 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
    • -
    • The check runs in production and staging only, so it will not reproduce against a local aggregator

    Example Error Response

    From d751dd4e4c063cdb6855831aafe04a4e31ec5f7e Mon Sep 17 00:00:00 2001 From: Chibuotu Amadi Date: Wed, 12 Aug 2026 11:36:57 +0100 Subject: [PATCH 5/5] docs: document providerIds parity semantics from aggregator #1020 Amends the queue docs for the follow-up that gave providerIds full pin parity (paycrest/aggregator#1020, merged). - Restricted providers are queueable by the senders their list names, rather than rejected outright - A fallback entry is accepted and ignored; the "cannot be queued explicitly" error is gone - A one-entry queue is exactly a pin, which is what lets integrators use providerIds everywhere and never reach for providerId - Onramp accepts destination.providerIds, capped at one entry - New behaviour change: pinning an onramp order to a provider restricted to other senders now 400s, and this applies to the singular providerId too Folded into the existing changelog entry rather than added as a second one -- neither PR has shipped to readers, so there is no before/after to describe, except the onramp authorization change, which existing providerId users will feel. Co-Authored-By: Claude Opus 5 --- api-reference/general/get-token-rate.mdx | 2 +- .../sender-api-integration.mdx | 34 +++++++++++--- .../smart-contract-interaction.mdx | 2 +- openapi-v1.yaml | 9 +++- openapi-v2.yaml | 45 ++++++++++++++----- resources/changelog.mdx | 13 ++++-- resources/troubleshooting.mdx | 2 +- 7 files changed, 83 insertions(+), 24 deletions(-) diff --git a/api-reference/general/get-token-rate.mdx b/api-reference/general/get-token-rate.mdx index e9cfc30..e2fb0aa 100644 --- a/api-reference/general/get-token-rate.mdx +++ b/api-reference/general/get-token-rate.mdx @@ -119,6 +119,6 @@ The queue is resolved **once, at order creation**, and the order carries it for **Exhausting the queue is not an immediate refund.** After the last entry, the order falls through to **fallback assignment**, and only then to refund after the refund window and its guards. Don't model this as "3 tries then refund".
    -Queues are built for **open, standard-tier** providers only. Pinned and restricted quotes return a single id, and the order routes to that one provider. +Quotes rank the **public** book, so `providerIds` lists only providers open to every sender. If you route to a provider dedicated to you, name it directly in **`destination.providerIds`** on create—it will not appear in a public quote. See also [Get Token Rate (v1)](/api-reference/general/get-token-rate-v1) for the legacy scalar response. diff --git a/implementation-guides/sender-api-integration.mdx b/implementation-guides/sender-api-integration.mdx index 31e3d8c..7d76f2e 100644 --- a/implementation-guides/sender-api-integration.mdx +++ b/implementation-guides/sender-api-integration.mdx @@ -385,8 +385,10 @@ Set **`destination.providerIds`** when you want routing pinned to providers you | Format | Each id is exactly **8 alphabetic** characters (`A–Z`, `a–z`). | | Mutually exclusive with `providerId` | Sending **both** returns **400**. To route to a single provider, send a one-entry `providerIds`—the singular `providerId` is retained for backward compatibility. | | No duplicates, no empty strings | Both return **400**. | -| Offramp only | `destination.providerIds` applies to `destination.type: "fiat"`. Onramp supports only the singular `providerId`. | -| Eligible providers only | Entries must be **open, standard-tier** providers serving this corridor. **Restricted** and **fallback** providers are rejected. | +| Providers you can route to | Multi-entry queues must name **active** providers with a **live standard offer** for this corridor. A provider restricted to specific senders is allowed when **you are on its list**. | +| Fallback is ignored, not rejected | Listing the fallback provider is accepted and has no effect—it is already tried after the queue is exhausted. | + +**A one-entry queue is a pin.** `providerIds: ["AbCdEfGh"]` routes exactly like `providerId: "AbCdEfGh"`, for every provider you're able to pin—the corridor checks above apply only to queues of two or more. That equivalence is what lets you use `providerIds` everywhere and never reach for the singular field. Validation failures return **400** with `message: "Failed to validate payload"` and `data` as a single object—`{ "field": "Destination", "message": "..." }`—not an array: @@ -397,14 +399,36 @@ Validation failures return **400** with `message: "Failed to validate payload"` | `Invalid provider ID "xyz". Expected 8 alphabetic characters` | Malformed entry | | `providerIds contains an empty provider ID` | Empty-string entry | | `providerIds contains duplicate provider ID: AbCdEfGh` | Repeated entry | -| `provider AbCdEfGh cannot be queued explicitly` | Entry is the fallback provider | -| `provider AbCdEfGh cannot serve this order` | Entry is inactive, restricted, or has no live offer for this corridor | +| `provider AbCdEfGh cannot serve this order` | Entry is inactive, has no live offer for this corridor, or is restricted to senders that don't include you | **Exhausting the queue does not refund immediately.** After the last entry, the order falls through to **fallback assignment**, and only then to refund after the refund window. `providerIds` is accepted on create but **not echoed back**—order responses and webhooks return the singular assigned **`providerId`**. -On the **v1** API the same field lives at **`recipient.providerIds`** with identical rules; its errors use `"field": "Recipient"`. +### Pinning on onramp + +Onramp picks its provider while the order is being created rather than walking a queue, so **`destination.providerIds` accepts at most one entry**—it is a pin in queue form, there so you never need the singular field: + +```json +"destination": { + "type": "crypto", + "currency": "USDT", + "providerIds": ["AbCdEfGh"], + "recipient": { "address": "0xRecipientWalletAddress", "network": "base" } +} +``` + +| `data.message` | Cause | +|----------------|-------| +| `providerIds accepts a single entry on onramp orders` | Two or more entries | +| `providerId and providerIds are mutually exclusive; provide only one` | Both fields sent | +| `provider AbCdEfGh cannot serve this order` | The provider is restricted to senders that don't include you | + + + Pinning an onramp order to a provider restricted to other senders returns **400**. Offramp has always behaved this way; onramp now matches, and it applies to **`providerId`** as well as `providerIds`. If you pin providers on onramp, confirm you're authorized for them. + + +On the **v1** API the offramp field lives at **`recipient.providerIds`** with identical rules; its errors use `"field": "Recipient"`. --- diff --git a/implementation-guides/smart-contract-interaction.mdx b/implementation-guides/smart-contract-interaction.mdx index 5faf7e9..b46356f 100644 --- a/implementation-guides/smart-contract-interaction.mdx +++ b/implementation-guides/smart-contract-interaction.mdx @@ -489,7 +489,7 @@ The recipient object may carry routing hints—this is the only channel for them The two are **mutually exclusive**. A hash carrying both is not rejected—the queue wins and `providerId` is ignored. A queue whose entries can't serve the corridor is **dropped silently** and the order routes as unpinned; there is no onchain rejection and no error webhook, so validate ids against a rate quote before encrypting. -Omit both and the aggregator resolves a queue itself at order creation. Entries must be **open, standard-tier** providers serving the corridor; restricted and fallback providers are rejected. +Omit both and the aggregator resolves a queue itself at order creation. Entries in a multi-entry queue must name providers that can serve the corridor—active, with a live standard offer, and, for a provider restricted to specific senders, one whose list includes the sender resolved from `metadata.apiKey`. A one-entry queue is simply a pin. The fallback provider may be listed but is ignored. **Budget ~450 bytes for recipient data.** `MESSAGE_HASH_MAX_SIZE` caps the recipient JSON at **500 bytes**, measured **before** encryption. The size check reserves room for a full 3-provider 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. diff --git a/openapi-v1.yaml b/openapi-v1.yaml index c5e9872..480582b 100644 --- a/openapi-v1.yaml +++ b/openapi-v1.yaml @@ -81,8 +81,13 @@ components: 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**. Entries must be open, standard-tier providers serving this corridor; - restricted and fallback providers are rejected. + 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 diff --git a/openapi-v2.yaml b/openapi-v2.yaml index 2f3ccf0..7a77da5 100644 --- a/openapi-v2.yaml +++ b/openapi-v2.yaml @@ -878,8 +878,12 @@ components: 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**. - Entries must be open, standard-tier providers serving this corridor; restricted and - fallback providers are rejected. + 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 @@ -929,7 +933,23 @@ components: description: Stablecoin symbol (e.g. USDT, USDC) providerId: type: string - description: Pin order to a specific provider (optional). Onramp does not support the `providerIds` queue. + 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' @@ -2028,13 +2048,18 @@ paths: - **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 (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. The singular `destination.providerId` is retained for - backward compatibility and is mutually exclusive with `providerIds`. Queue exhaustion is not an immediate - refund — fallback assignment runs first, then the refund window. Provider-queue validation failures return - **400** with `data` as a single `{ field, message }` object rather than an array. + **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: diff --git a/resources/changelog.mdx b/resources/changelog.mdx index a329db3..acbe50c 100644 --- a/resources/changelog.mdx +++ b/resources/changelog.mdx @@ -21,19 +21,24 @@ Order routing is now **optimistic**: a ranked queue of up to **3 providers** is 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` (offramp), `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. +**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** | -| Max 3 entries, no duplicates, no empty strings | **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 | Open, standard-tier providers serving the corridor. Restricted and fallback providers are rejected | -| Scope | Offramp only. Onramp keeps the singular `providerId` | +| 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). diff --git a/resources/troubleshooting.mdx b/resources/troubleshooting.mdx index 6bc87be..958c769 100644 --- a/resources/troubleshooting.mdx +++ b/resources/troubleshooting.mdx @@ -36,7 +36,7 @@ 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 queue rejected: providerIds and providerId are mutually exclusive, capped at 3 entries, and must name open standard-tier providers serving the corridor. See Pin a provider queue
  • +
  • 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"