Currency Exchange
- Base path
/api/v1/bank-connect- Identity
- Ed25519 request signature on every request —
X-Key-Id,X-Date,X-Nonce,X-Signature. ±5 min clock window, single-use nonce. A Bearer token or a panel session is not accepted here (401 AUTH_HEADERS_MISSING). - Authorization
- The key must carry the Bank Connections product (
BANK_CONNECT) and the permission for the endpoints on this page:bank-connect:transfer. Without the product the call gets403 PRODUCT_NOT_ENABLED; without the permission403 API_INSUFFICIENT_SCOPE. See API Introduction › Permissions. - Cross-surface
- These endpoints are the s2s surface — call them from your own server with a signed request. The same data has screens in the panel, but panel endpoints cannot be called with an API key, and these endpoints cannot be called with a panel session. Connecting and disconnecting a bank is done only in the panel.
If your connection supports currency exchange, you can buy and sell currency between two of your accounts at the same bank: you sell from one account and buy into your other account in a different currency. Whether a connection supports it is in the capabilities field of the Connections endpoint: the list must include FX_EXCHANGE.
An exchange made with an API key needs no e-mail verification code. The permission and IP allowlist rules are the same as for Money Transfers: your key must carry the bank-connect:transfer permission and an IP allowlist, and the request must come from a listed address.
Flow
expiresAt. Read the outcome from the order endpoint — no webhook is sent for exchange orders.See the rates (optional)
GET /fx/rates returns the connection's current buy and sell rates. Rates are informational; the rate you get is given in the quote.
Get a quote
Send the source account (you sell), the target account (you buy) and the amount to POST /fx/quotes. The bank gives the rate and the amount on both sides. A quote blocks nothing.
Execute the quote before it expires
Send the quote's id to POST /fx/orders. The bank sets how long a quote is valid, and it is short (usually a minute or two); open the order before the quote's expiresAt. The order is opened SUBMITTED and sent to the bank.
Read the outcome
Use GET /fx/orders/{orderId} to see whether the order is COMPLETED or FAILED. No webhook is sent for exchange orders.
Endpoints
| Method | Path | Description |
|---|---|---|
GET | /fx/rates?connectionId=… | Current rates on the connection |
POST | /fx/quotes | Exchange quote |
POST | /fx/orders | Executes a quote. Idempotency-Key required. |
GET | /fx/orders | Lists exchange orders |
GET | /fx/orders/{orderId} | A single exchange order |
Base URL: https://api-gateway.lunixi.com/api/v1/bank-connect. Signing requests with a body: Money Transfers › Signing a request with a body. The PHP examples use $bankConnect->fx() of SDK 0.22.0 or later.
Rates
GET /api/v1/bank-connect/fx/rates?connectionId={connectionId}
{
"status": "success",
"code": "SUCCESS",
"data": {
"items": [
{ "currency": "USD", "pairCurrency": "TRY", "buyRate": "34.0100", "sellRate": "34.2400", "spread": null, "updatedAt": "2026-01-01T07:00:00.000Z" }
],
"refreshSeconds": 30,
"fetchedAt": "2026-01-01T07:00:00.000Z"
},
"statusCode": 200,
"requestId": "00000000-0000-0000-0000-000000000000"
}| Field | Description |
|---|---|
items[].currency / pairCurrency | The rate's currency and its counter currency. |
items[].buyRate / sellRate | The bank's buy and sell rate, returned as a decimal string. Use it without converting to a floating-point number. |
items[].spread | The buy-sell spread when the bank reports it; otherwise null. |
refreshSeconds | How often the bank refreshes the rates (seconds). Polling more often brings nothing. |
fetchedAt | When the rates were fetched from the bank. |
Getting a quote
POST /api/v1/bank-connect/fx/quotes
| Body field | Type | Required | Description |
|---|---|---|---|
sourceAccountId | string (UUID) | Yes | The account of the currency you sell (the money leaves it). |
targetAccountId | string (UUID) | Yes | The account of the currency you buy (the money enters it). Same connection, different currency. |
sourceAmountMinor | integer | One of the two | The amount you want to sell, in minor units of the source account's currency. |
targetAmountMinor | integer | One of the two | The amount you want to buy, in minor units of the target account's currency. |
Send exactly one of sourceAmountMinor and targetAmountMinor. A quote request takes no Idempotency-Key; every request makes a new quote.
source lunixi-sign.sh
REQ_PATH="/api/v1/bank-connect/fx/quotes"
BODY='{"sourceAccountId":"00000000-0000-0000-0000-000000000100","targetAccountId":"00000000-0000-0000-0000-000000000101","targetAmountMinor":100000}'
lunixi_sign POST "$REQ_PATH" "$BODY"
curl -X POST "https://api-gateway.lunixi.com$REQ_PATH" \
-H "Content-Type: application/json" \
-H "X-Key-Id: $LUNIXI_KEY_ID" \
-H "X-Date: $DATE" \
-H "X-Nonce: $NONCE" \
-H "Digest: $DIGEST" \
-H "X-Signature: $SIGNATURE" \
--data-raw "$BODY"A successful response is 201; data is the quote object:
{
"status": "success",
"code": "SUCCESS",
"data": {
"id": "00000000-0000-0000-0000-000000003000",
"connectionId": "00000000-0000-0000-0000-000000000010",
"sourceAccountId": "00000000-0000-0000-0000-000000000100",
"targetAccountId": "00000000-0000-0000-0000-000000000101",
"sourceCurrency": "TRY",
"targetCurrency": "USD",
"sourceAmountMinor": 3412500,
"targetAmountMinor": 100000,
"requestedSide": "TARGET",
"appliedRate": "34.1250",
"taxAmountMinor": null,
"expiresAt": "2026-01-01T07:01:20.000Z",
"validForSeconds": 80,
"insufficientBalance": false,
"belowMinimum": false,
"createdAt": "2026-01-01T07:00:00.000Z"
},
"statusCode": 201,
"requestId": "00000000-0000-0000-0000-000000000000"
}The quote object
| Field | Type | Description |
|---|---|---|
id | string (UUID) | The quote's id; send it as quoteId when opening an order. |
connectionId | string (UUID) | The accounts' connection. |
sourceAccountId / targetAccountId | string (UUID) | The account you sell from and the account you buy into. |
sourceCurrency / targetCurrency | string | The two accounts' currencies. |
sourceAmountMinor | integer | The amount that leaves the source account, in minor units. |
targetAmountMinor | integer | The amount that enters the target account, in minor units. |
requestedSide | string | Which amount you gave: SOURCE or TARGET. The bank calculates the other one. |
appliedRate | string | The rate that will apply, as a decimal string. |
taxAmountMinor | integer | null | The tax on the exchange, when the bank reports it. |
expiresAt | string | null | When the quote expires. Open the order before this time. |
validForSeconds | integer | Seconds of validity left when the response was sent. |
insufficientBalance | boolean | true: the bank warns that the source balance may not be enough. |
belowMinimum | boolean | true: the amount is below the bank minimum. |
createdAt | string | null | When the quote was created. |
Opening an order
POST /api/v1/bank-connect/fx/orders
| Header | Value |
|---|---|
Idempotency-Key | Required. 8–200 printable ASCII characters. Same rules as transfers. → Money Transfers › Idempotency-Key |
| Body field | Type | Required | Description |
|---|---|---|---|
quoteId | string (UUID) | Yes | The id of a quote that has not expired and has not been used. |
description | string | No | Description, at most 140 characters. |
source lunixi-sign.sh
REQ_PATH="/api/v1/bank-connect/fx/orders"
BODY='{"quoteId":"00000000-0000-0000-0000-000000003000"}'
lunixi_sign POST "$REQ_PATH" "$BODY"
curl -X POST "https://api-gateway.lunixi.com$REQ_PATH" \
-H "Content-Type: application/json" \
-H "X-Key-Id: $LUNIXI_KEY_ID" \
-H "X-Date: $DATE" \
-H "X-Nonce: $NONCE" \
-H "Digest: $DIGEST" \
-H "X-Signature: $SIGNATURE" \
-H "Idempotency-Key: fx-2026-0042" \
--data-raw "$BODY"A successful response is 201: data.order is the exchange order, data.replayed tells whether an existing order with the same key and body was returned.
{
"status": "success",
"code": "SUCCESS",
"data": {
"order": {
"id": "00000000-0000-0000-0000-000000004000",
"quoteId": "00000000-0000-0000-0000-000000003000",
"connectionId": "00000000-0000-0000-0000-000000000010",
"sourceAccountId": "00000000-0000-0000-0000-000000000100",
"targetAccountId": "00000000-0000-0000-0000-000000000101",
"sourceCurrency": "TRY",
"targetCurrency": "USD",
"sourceAmountMinor": 3412500,
"targetAmountMinor": 100000,
"appliedRate": "34.1250",
"description": null,
"idempotencyKey": "fx-2026-0042",
"status": "SUBMITTED",
"failureCode": null,
"outcomeUnknown": false,
"initiatedVia": "API",
"sourceBalanceAfterMinor": null,
"targetBalanceAfterMinor": null,
"createdAt": "2026-01-01T07:00:30.000Z",
"submittedAt": "2026-01-01T07:00:30.000Z",
"completedAt": null,
"failedAt": null,
"fxSessionId": null
},
"replayed": false
},
"statusCode": 201,
"requestId": "00000000-0000-0000-0000-000000000000"
}A quote serves one order. An expired quote returns 409 bank_connect.fx.quote_expired, a used quote 409 bank_connect.fx.quote_used; get a new quote. If the quote expires after the order was opened but before it reached the bank, the order becomes FAILED with failureCode bank_connect.fx.quote_expired.
Tracking exchange orders
GET /api/v1/bank-connect/fx/orders/{orderId}returns one order. An unknown order, or one that belongs to another organization or the other environment, returns404 bank_connect.fx.order_not_found.GET /api/v1/bank-connect/fx/orderslists orders newest first. Filters:status(comma-separated:SUBMITTED,COMPLETED,FAILED),limit(1–100),cursor.
When outcomeUnknown is true, the outcome is not confirmed by the bank yet; do not open the same order again. Lunixi confirms the outcome with the bank and updates the order.
The exchange order object
A field without a value is null.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | The order's id. |
quoteId | string (UUID) | The order's quote. |
connectionId, sourceAccountId, targetAccountId | string (UUID) | The connection and the two accounts. |
sourceCurrency / targetCurrency | string | The two accounts' currencies. |
sourceAmountMinor / targetAmountMinor | integer | The amounts of the quote, in minor units. |
appliedRate | string | The applied rate, as a decimal string. |
description | string | null | The description you sent. |
idempotencyKey | string | The Idempotency-Key the order was created with. |
status | string | SUBMITTED, COMPLETED or FAILED. → Concepts › Currency exchange |
failureCode | string | null | Reason code of a FAILED order (bank_connect.fx.*). → Error Codes › Exchange result codes |
outcomeUnknown | boolean | true: the outcome is not confirmed by the bank yet. |
initiatedVia | string | API or PANEL. |
sourceBalanceAfterMinor / targetBalanceAfterMinor | integer | null | The two accounts' balances after the exchange, when the bank reports them. |
createdAt, submittedAt, completedAt, failedAt | string | null | Timestamps. |
fxSessionId | string | null | Set only on orders opened in the panel; null on API orders. |
Errors
| HTTP | code | What to do |
|---|---|---|
400 | VALIDATION_ERROR | You sent an undeclared field or parameter, or a field is malformed. |
400 | bank_connect.validation.invalid | Both amounts or neither were sent, the two accounts are the same, or the description breaks a rule; see details.errors[]. |
400 | bank_connect.transfer.idempotency_key_required | Add the Idempotency-Key header when opening an order. |
403 | API_INSUFFICIENT_SCOPE | Add the bank-connect:transfer permission to your key. |
403 | bank_connect.auth.ip_allowlist_required / bank_connect.auth.ip_not_allowed | Set an IP allowlist, or send the request from a listed address. |
404 | bank_connect.connection.not_found / bank_connect.account.not_found | Check the connection or account id. |
404 | bank_connect.fx.quote_not_found / bank_connect.fx.order_not_found | Check the id and your key's environment. |
409 | bank_connect.fx.quote_expired / bank_connect.fx.quote_used | Get a new quote. |
409 | bank_connect.fx.idempotency_conflict | You used this key with another body; use a new key for a new order. |
409 | bank_connect.transfer.account_not_eligible | The account, connection or bank details are not active right now. |
412 | bank_connect.fx.capability_unavailable | This connection does not support currency exchange. |
422 | bank_connect.fx.accounts_invalid | Pick two active accounts in different currencies on the same connection. |
422 | bank_connect.fx.pair_unsupported | The bank does not exchange this currency pair. |
422 | bank_connect.fx.quote_rejected | The bank gave no quote; the reason is in details.reasonCode. |
429 | RATE_LIMITED | The request limit was exceeded; wait Retry-After. → Limits |