Rate Locks
A rate lock is one of the two ways to price 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. 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 for a currency pair. You supply only the pair — the base currency you price in and the quote currency you collect in:
We return the lock with its frozen rate and expiry:
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:
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.
Rate locks price 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 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. 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 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.
Two more can occur, with a different status:
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.cancelledwebhook fires withcancellationReason: 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
expiresAtyou were given when the lock was created.
In every case Get a 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. 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 — read it back with Get a 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 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 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 with its rateLockId:
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.
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, 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 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 automatically.