Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api-reference/errors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 33 additions & 6 deletions api-reference/general/get-token-rate.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`)
Expand All @@ -53,9 +57,9 @@ 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 |
| `orderType` | string | e.g. `regular` or `otc` |
| `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` |
| `refundTimeoutMinutes` | integer | Minutes until automatic refund for that flow |

```json
Expand All @@ -71,7 +75,7 @@ Each populated side includes:
},
"sell": {
"rate": "1500.50",
"providerIds": ["AbCdEfGh"],
"providerIds": ["AbCdEfGh", "IjKlMnOp", "QrStUvWx"],
"orderType": "regular",
"refundTimeoutMinutes": 60
}
Expand All @@ -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=` or `?limit=1`. Fewer than 3 ids 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 |
Expand All @@ -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.

The queue is resolved **once, at order creation**, and the order carries it for its whole life. Two ways to use that:

<Steps>
<Step title="Let the API resolve it">
Omit **`destination.providerId`** and **`destination.providerIds`** on create. The API resolves a fresh queue at creation time. Nothing to do—this is the default.
</Step>
<Step title="Pass the queue you quoted">
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).
</Step>
</Steps>

<Note>
**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".
</Note>

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.
1 change: 0 additions & 1 deletion concepts/participants.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@ A **KYB-verified participant** that supplies fiat liquidity in exchange for stab
<Card title="Crypto-Native Providers">
- Crypto exchanges
- DeFi protocols
- OTC desks
- Crypto payment processors
</Card>
</CardGroup>
Expand Down
80 changes: 80 additions & 0 deletions implementation-guides/sender-api-integration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,78 @@ 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**. 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**. |
| 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:

| `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 serve this order` | Entry is inactive, has no live offer for this corridor, or is restricted to senders that don't include you |

<Note>
**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`**.
</Note>

### 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 |

<Warning>
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.
</Warning>

On the **v1** API the offramp field lives at **`recipient.providerIds`** with identical rules; its errors use `"field": "Recipient"`.

---

## KES mobile money (M-Pesa, Till, Paybill)
Expand Down Expand Up @@ -698,6 +770,8 @@ A `200 OK` response returns the resolved name in `data`. If `data` is a real nam

<Note>
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.
</Note>

<Tabs>
Expand All @@ -711,6 +785,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 ?? [];
```
</Tab>
<Tab title="Python">
Expand All @@ -720,6 +797,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 []
```
</Tab>
</Tabs>
Expand Down
25 changes: 25 additions & 0 deletions implementation-guides/smart-contract-interaction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,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).
</Note>

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)).

<Tabs>
<Tab title="JavaScript">
```javascript
Expand Down Expand Up @@ -476,6 +478,29 @@ 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).
</Note>

### 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 |
|-------|-------------|
| `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 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.

<Warning>
**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).
</Warning>

<Note>
**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.
</Note>

<Tabs>
<Tab title="JavaScript">
```javascript
Expand Down
22 changes: 21 additions & 1 deletion openapi-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading