> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.crisscross.money/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.crisscross.money/_mcp/server.

# Rate Locks

> Fetch a rate lock once and reuse it across multiple checkout sessions until it expires — one held rate for every payment.

A rate lock is one of the two ways to price [Adaptive Currency Conversion](/adaptive-currency-conversion) — the alternative to the default per-session rate minted at checkout. It lets you fetch an FX rate once and reuse it across many transactions, instead of a fresh quote for every session. It's built for merchants who price in one currency (for example USD) and collect in another (for example KES): lock the rate, price your orders against it, and settle every transaction at the locked rate.

A rate lock is a *held rate* — not a [quote](/exchange-trading). A quote is a firm price for a single trade that you accept within seconds. A lock is a rate frozen for hours and reusable across many transactions. It ends at its `expiresAt` — or occasionally earlier if the market moves sharply — and you're notified by webhook either way.

The lifecycle is **create a lock → reuse it across checkouts → it expires or is cancelled → fetch a new one**.

## Create a lock

[Create a rate lock](/api-reference/collect/rate-locks/create-rate-lock) for a currency pair. You supply only the pair — the base currency you price in and the quote currency you collect in:

```
POST /v1/rate-locks
```

```json
{
  "base": "USD",
  "quote": "KES"
}
```

We return the lock with its frozen rate and expiry:

```json
{
  "rateLockId": "rlk_550e8400-e29b-41d4-a716-446655440000",
  "status": "active",
  "base": "USD",
  "quote": "KES",
  "midRate": "130.00",
  "markupBps": 200,
  "allInRate": "132.60",
  "createdAt": "2026-06-24T10:30:00Z",
  "expiresAt": "2026-06-24T22:30:00Z"
}
```

No amount is involved at this stage — a lock is a rate, not a trade. The rate, markup, and expiry are fixed when the lock is created and stay constant for its lifetime.

## How a lock is priced

A lock is priced as a mid-market reference rate plus a markup, and you see the whole breakdown:

| Field       | Meaning                                                                                            |
| ----------- | -------------------------------------------------------------------------------------------------- |
| `midRate`   | The reference mid-market rate the lock was priced from.                                            |
| `markupBps` | The markup applied over the mid, in basis points (100 bps = 1%).                                   |
| `allInRate` | The rate you transact at — mid plus markup. Every transaction using the lock settles at this rate. |

The markup is configured on your account per currency pair, so you can choose how much of the spread to pass on to your payers. Talk to us if you want it adjusted.

> **Note**
>
> Rate locks price [Adaptive Currency Conversion](/adaptive-currency-conversion), which we enable on
> your account — there is no self-serve toggle. Until it is enabled, creating a session that needs a
> conversion is rejected with `403` and `error: "fx_not_enabled"` before the lock is even looked at.
> [Get in touch](/support) to have it enabled, and to set the markup for the pairs you need.

## Reuse across checkout sessions

Pass the `rateLockId` when you [create a checkout session](/hosted-checkout). You price the session in the lock's base currency, and the payer is charged the equivalent in the quote currency at the lock's `allInRate`. The session `currency` must match the lock's base currency, and the session's collection currency (defaulted from the payer's location if you don't set it) must match the lock's quote currency.

The create response returns the priced conversion as `fxCommit` — `collectionAmount` in the quote currency alongside the `amount` you sent in the base currency — so a direct integration can show the payer both amounts without reading the session back:

```json
{
  "sessionId": "01951c8a-7c3d-7e1f-9d4a-2b3c4d5e6f70",
  "payerId": "2a1c4f1a-0d44-4f16-8c8e-9a3b4c5d6e7f",
  "fxCommit": {
    "quoteId": "rlk_550e8400-e29b-41d4-a716-446655440000",
    "pricingModel": "RATE_LOCK",
    "collectionCurrency": "KES",
    "collectionAmount": "13260.00",
    "midRate": "130.00",
    "markupBps": 200,
    "allInRate": "132.60",
    "fee": "260.00",
    "expiresAt": "2026-06-24T22:30:00Z"
  }
}
```

The lock is verified twice: when you attach it at session creation, and again when a payment is initiated. If it is still valid, the payment proceeds at the locked rate. A transaction that has already executed is always honoured at its locked rate — cancellation only blocks new use.

## When a lock is rejected

A rejected lock returns `422` with a machine-readable `error` code. Several rejections share that status, so **branch on `error`, not on the status**.

| `error`                       | What happened                                                      | What to do                                                                                       |
| ----------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| `rate_lock_invalid`           | The lock is no longer usable — expired, or cancelled               | Create a new lock and price the session against it                                               |
| `rate_lock_not_found`         | No lock with that id                                               | Check the `rateLockId` you sent                                                                  |
| `rate_lock_currency_mismatch` | The lock's `base` doesn't match the session `currency`             | Price the session in the lock's base currency                                                    |
| `rate_lock_currency_mismatch` | The lock's `quote` doesn't match the session's collection currency | Set `collectionCurrency` to the lock's quote, or send a payer location whose currency matches it |

Two more can occur, with a different status:

| HTTP  | `error`                 | What happened                                                                            | What to do                     |
| ----- | ----------------------- | ---------------------------------------------------------------------------------------- | ------------------------------ |
| `400` | `rate_lock_id_required` | Your account prices conversions with rate locks, but the session carried no `rateLockId` | Create a lock and pass it      |
| `502` | `rate_lock_unavailable` | We could not reach the rate-lock service                                                 | Transient — retry with backoff |

```json
{
  "message": "Rate lock has expired or been cancelled; fetch a new lock",
  "error": "rate_lock_invalid",
  "statusCode": 422
}
```

Two behaviours worth knowing:

**Expiry and cancellation are indistinguishable.** A lock that ran out of time, one we cancelled for market drift, and one you cancelled yourself all return exactly the response above — the rejection carries no reason. To find out *why* a lock ended:

* **Market drift** — the [`rate_lock.cancelled`](/api-reference/collect/rate-locks) webhook fires with `cancellationReason: market_drift`. This is the only case you would not otherwise know about, which is why it is the only one that notifies.
* **You cancelled it** — no webhook is sent, since you made the call. The lock stays readable with `cancellationReason: requested`.
* **It expired** — compare against the `expiresAt` you were given when the lock was created.

In every case [Get a rate lock](/api-reference/collect/rate-locks/get-rate-lock) returns the lock's current `status` and `cancellationReason`, so a single read settles it.

**The two checks differ.** At session creation the lock is checked in order: it must exist, its currencies must match the session, and only then must it still be usable — so a currency mismatch is reported before expiry is ever evaluated. At payment initiation only existence and usability are re-checked; currencies were settled when the lock was attached, so `rate_lock_currency_mismatch` cannot occur there.

## Validity and expiry

Every lock has a validity window, returned as `expiresAt` — valid for hours rather than the seconds of a single quote. A lock at or past its `expiresAt` is no longer usable, even if you haven't yet fetched it to see the change — always check `expiresAt`, or read the lock with [Get a rate lock](/api-reference/collect/rate-locks/get-rate-lock). When the window elapses we also notify you with a `rate_lock.expired` webhook, so you don't need to rely on polling. Once a lock expires, fetch a new one.

## Rolling to a new lock before expiry

You can hold up to **two active locks per currency pair** at once, so you can fetch the next lock before the current one expires and avoid a price cliff for shoppers already browsing. Fetch the replacement shortly before expiry (about an hour ahead is typical): keep pricing existing browsing sessions off the current lock until it expires, and send new browsing sessions to the new lock. Each checkout session keeps the lock it was priced against — returned on creation and by [Get a checkout session](/api-reference/collect/payments/payment-initiation/get-checkout-session) as `fxCommit.quoteId`, with `fxCommit.pricingModel` set to `RATE_LOCK` — and any transaction already in progress is honoured at its locked rate regardless of which lock expires.

If you [create a lock](/api-reference/collect/rate-locks/create-rate-lock) while two are already active for the pair, we return the freshest of the two (`200`) rather than minting a third.

## Drift cancellation

A lock can occasionally end before its `expiresAt`. If the market rate moves sharply beyond the lock's tolerance — in either direction — the lock is cancelled early, and we notify you with a `rate_lock.cancelled` webhook. (Bounding drift this way is what makes an hours-long locked window possible in the first place.)

Treat the webhook as your signal to re-lock: it carries no new rate, so call [Create a rate lock](/api-reference/collect/rate-locks/create-rate-lock) and price new sessions off the fresh lock. Shoppers mid-payment are unaffected — a transaction already in progress settles at its locked rate; cancellation only blocks new use.

A cancellation is permanent. A lock cancelled by drift stays cancelled even if the market moves back.

## Cancel a lock

You can also cancel an active lock yourself before it expires — for example, if you re-price your catalogue and no longer need it. [Cancel a rate lock](/api-reference/collect/rate-locks/cancel-rate-lock) with its `rateLockId`:

```
POST /v1/rate-locks/{rateLockId}/cancel
```

The lock moves to `cancelled` (with `cancellationReason: requested`) and can no longer be used for new checkouts, but stays readable. As with drift, cancellation is permanent and does not affect any transaction already in progress, which is always honoured at its locked rate. The call is idempotent: cancelling a lock that has already expired or been cancelled returns its current state.

No webhook is sent for a cancellation you request yourself — `rate_lock.cancelled` fires only when we cancel a lock due to market drift.

## Reconciliation

Every transaction attempt carries an immutable snapshot of the rate it was charged at — the same FX snapshot fields as any converted transaction: `collection` (`{ amount, currency }` the payer was debited), `fxMidRate`, `fxAllInRate`, `fxMarkupBps`, and `fxFee`. Read them back per transaction with [Retrieve Payment Status](/api-reference/collect/payments/manage-payments/get-transaction).

Because a lock's rate is frozen, every transaction priced by the same lock reconciles at the same `fxAllInRate` — unlike [ACC's default per-session rate](/adaptive-currency-conversion), where each session gets its own quote. To group transactions by lock, match the session's `fxCommit.quoteId` to the `rlk_` id you created.

## Locks vs quotes

Use a **rate lock** when you price in one currency and collect in another and want one rate to hold across many checkouts. Use a [**quote**](/exchange-trading) when you want to execute a single one-off trade at a firm rate. Merchants who don't bring a lock to checkout fall back to [ACC's default per-session rate](/adaptive-currency-conversion) automatically.