An offramp order carries a bank payout amount, a deposit address and a rate, so reading one back requires proof of having created it.
POST /offramp/orders returns two values the client must keep:
| Field | Example | Notes |
|---|---|---|
id |
ofr_ + 22 URL-safe characters (128 bits) |
The id to look the order up by. Not the provider's id, which is never returned. |
accessToken |
oft_ + 43 URL-safe characters (256 bits) |
Bearer credential. Shown once; only its SHA-256 is stored. |
The response is sent with Cache-Control: no-store.
Send either amountNGN or amountStableCoin, as a JSON number or a plain decimal
string. Anything else ("abc", a negative or zero amount, "Infinity", a boolean,
an array) is refused with 400 before any lookup or provider call. Each currency
has its own ceiling:
| Variable | Default | Purpose |
|---|---|---|
OFFRAMP_MAX_AMOUNT_NGN |
10000000 |
Largest amountNGN accepted. |
OFFRAMP_MAX_AMOUNT_STABLECOIN |
10000 |
Largest amountStableCoin accepted. |
A value that is not a positive number is ignored and the default applies.
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
https://<host>/offramp/orders/$ORDER_ID| Status | Meaning |
|---|---|
200 |
The token belongs to this order. |
401 |
No usable Authorization: Bearer header. Identical for every id. code: unauthorized |
404 |
Unknown id, malformed id, or a token that does not belong to the order. These are deliberately indistinguishable. code: order_not_found |
429 |
Too many failed lookups from this IP. code: veil_rate_limited |
Only failed lookups count toward the limit, so polling your own order never consumes it. Defaults: 10 failures per 15 minutes per IP.
| Variable | Default | Purpose |
|---|---|---|
OFFRAMP_LOOKUP_MAX_FAILURES |
10 |
Failed lookups allowed per window. |
OFFRAMP_LOOKUP_WINDOW_MS |
900000 |
Window length. |
Replaying POST /offramp/orders with the same idempotencyKey and an identical
request returns the same order (replayed: true) with the same id and a new
accessToken; the previous token stops working. A replay only counts as identical
when the wallet, the amount, bankAccount, bankCode, accountName and
refundAddress all match:
| Replay differs in | Result |
|---|---|
walletAddress |
409, and nothing about the order. |
| the amount | 409 (different request). |
| bank account, bank code, account name or refund address | 409 (different order details). |
All three carry code: idempotency_conflict; the prose says which difference
tripled it. Every error on this rail answers { error, code } — the full code
catalogue is in ngn-rails.md.
Idempotency keys should therefore be random and generated by the client.
Two simultaneous requests with the same key resolve the same way: one creates the
order, the other is answered as the replay (200, replayed: true) rather than
with an error, after the same wallet and request checks. Each replay issues a new
token, so the token from the most recent response is the one that works.
The request is compared through a stored HMAC-SHA256 fingerprint keyed with
LINQ_API_KEY, not by storing the bank details. Rotating that key means a retry
of an order created before the rotation is refused with 409.
GET /offramp/availability → 200 { available: true } cash-out is enabled here
503 { error, code: "not_configured" } no payout key here
This is what a client should call when it wants to know whether to show a cash-out CTA. It makes no provider call, so it spends none of the rate allowance every user shares, and it never returns a number a client might mistake for a price.
It exists because there was no other way to ask. isOfframpAvailable() in the
mobile client called /offramp/rate and read any error as "cash-out is down",
which meant a pricing endpoint was the health check for the feature — and any
change to the rate contract, including the staged coin requirement below, was
an outage for every build that gated on it.
Gate on the body, not only the status: a proxy error page, a redirect, or a captive portal can all arrive as a response, and a client that treats "got a reply" as "available" will offer cash-out and then fail on the first tap.
The answer is served with Cache-Control: no-store, so a 200 cached before a
rollback cannot keep offering a tile whose every other route now 503s.
GET /offramp/rate?coin=xlm → 200 { coin: "xlm", rate: <NGN per 1 XLM>, indicative: true }
GET /offramp/rate?coin=usdc → 200 { coin: "usdc", rate: <NGN per 1 USDC>, indicative: true }
GET /offramp/rate → 200 { coin: "usdc", … } + a deprecation log line
GET /offramp/rate?coin=doge → 400 { error, code: "invalid_coin" }
GET /offramp/rate?coin= → 400 { error, code: "invalid_coin" }
rate is naira per one unit of the coin named, never naira per dollar, and
it comes from ngnPerCoin() — the same path the onramp quotes with, so the two
rails cannot disagree. The response always carries the coin it was priced for.
Pricing a coin as dollars is the defect that cost real money on the onramp: ₦1,000
bought 0.6 XLM instead of 3.6, because a dollar rate was divided as though it
were an XLM rate. So coin is on its way to being mandatory.
Because installed APKs cannot be updated from this repository. The mobile client
asks isOfframpAvailable(), which calls this endpoint and reads any error as
"cash-out is down" and hides the tile. A 400 on a coin-less request therefore
does not tell anyone to pick a coin — it removes cash-out for every user on an
older build, silently, and looks like an outage. #216 was this mistake, and the
lesson recorded from it was: ship the client first.
Answering is not the silent defaulting this endpoint exists to prevent. A USDC is a dollar, so the USDC rate is the number a coin-less caller gets from today's deployment: unchanged for the callers that cannot change yet, wrong for XLM in exactly the way it is already wrong, and now labelled with the coin it is priced for so a client can see the difference.
- Here. An unknown coin 400s — new behaviour, nothing depends on it. A
missing coin is answered as
usdcand logged as deprecated, once per User-Agent, so the log reads as a list of callers rather than a flood. - Clients. Both wallet builds send
coinon every rate call, and the availability check moves toGET /offramp/availabilityinstead of borrowing the rate endpoint. The client work lives in the wallet repos, not here, and it is the half that actually fixes XLM: the server cannot update an installed APK. - Then. When a build carrying stage 2 is actually released and the
deprecation line has been quiet for a full release cycle, a missing coin 400s
and
logCoinlessRateRequestis deleted along with the compatibility default.
Stage 3 is a breaking change to a money-moving endpoint. Do not make it from the server side alone: it needs evidence from the log, not a judgement that enough time has passed.
Written up as the contract first; the clients now live in
Miracle656/veil, and stage 2 is
veil#1124. Four call sites, one
rate and one availability probe per client:
call('/offramp/rate')becomes a call that sendscoin. Each cash-out screen names the asset it moves — both of them are USDC-only today, so a per-screen constant is right, as long as the constant says which asset and sits on the screen that knows. Alibthat hard-codesusdcon the caller's behalf is what made this invisible in the first place.isOfframpAvailable()moves to/offramp/availability, and treats a non-200 or a body withoutavailable: trueas unavailable. A 200 from an edge proxy parses to{}, and an unread "yes" offers a flow that fails on the first tap.- The cached availability flag keeps its key. A build holding a cached
0from the rate-based probe needs no migration: the stored meaning is unchanged ("the rails answered"), and every launch runs a live probe that overwrites it. This was first written as needing a bump, which was wrong. - A screen showing a quote reads
coinback off the response instead of assuming its request was honoured, and refuses a quote for anything else. That field is the only signal a build has that it is on the deprecated path — andngn / ratedecides how much asset a user sends, so a USDC number answering toxlmis not a rounding error.
A build that ships only the availability half stays compatible with stage 1 — that is the point of staging it this way.
A key is scoped to one network and never expires: once used, it belongs to that
order for good. Generate a new one per order and resend it only to retry that
order. A 500 on creation does not mean no order exists, so retry with the
same key rather than a new one. The details are in idempotency.md.
- Orders created before this change have no
idor token and cannot be read throughGET /offramp/orders/:orderId. Replaying their creation request (same key and wallet) issues them credentials. - The lookup response now reports
source: "live"instead of"linq". - Two nullable columns were added to
OfframpOrder(publicId,accessTokenHash); runnpx prisma db push. - A third nullable column,
requestHash, binds an idempotency key to the request that created the order (npx prisma db pushadds it; the server also does so at boot). Orders created before it have none and are compared on amount alone.