Bank Accounts
API Reference
Currency Exchange

Currency Exchange

Who calls these endpoints?External API (s2s)
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 gets 403 PRODUCT_NOT_ENABLED; without the permission 403 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

Currency exchange flowFlow: optionally read the rates, get a short-lived quote, execute it as an order before it expires; the order opens SUBMITTED and becomes COMPLETED or FAILED from the bank answer, which you read from the order endpoint because no webhook is sent.BEFORE EXPIRYRatesGET /fx/ratesQuotePOST /fx/quotesOrderPOST /fx/ordersCOMPLETEDFAILEDoptionalvalid ~1–2 min · expiresAtopens as SUBMITTEDGET /fx/orders/{orderId}no webhook — read the orderLEGENDOPTIONALSHORT-LIVEDSENT TO THE BANKFINAL
The quote blocks nothing and is short-lived; open the order before its 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

MethodPathDescription
GET/fx/rates?connectionId=…Current rates on the connection
POST/fx/quotesExchange quote
POST/fx/ordersExecutes a quote. Idempotency-Key required.
GET/fx/ordersLists 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"
}
FieldDescription
items[].currency / pairCurrencyThe rate's currency and its counter currency.
items[].buyRate / sellRateThe bank's buy and sell rate, returned as a decimal string. Use it without converting to a floating-point number.
items[].spreadThe buy-sell spread when the bank reports it; otherwise null.
refreshSecondsHow often the bank refreshes the rates (seconds). Polling more often brings nothing.
fetchedAtWhen the rates were fetched from the bank.

Getting a quote

POST /api/v1/bank-connect/fx/quotes

Body fieldTypeRequiredDescription
sourceAccountIdstring (UUID)YesThe account of the currency you sell (the money leaves it).
targetAccountIdstring (UUID)YesThe account of the currency you buy (the money enters it). Same connection, different currency.
sourceAmountMinorintegerOne of the twoThe amount you want to sell, in minor units of the source account's currency.
targetAmountMinorintegerOne of the twoThe 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

FieldTypeDescription
idstring (UUID)The quote's id; send it as quoteId when opening an order.
connectionIdstring (UUID)The accounts' connection.
sourceAccountId / targetAccountIdstring (UUID)The account you sell from and the account you buy into.
sourceCurrency / targetCurrencystringThe two accounts' currencies.
sourceAmountMinorintegerThe amount that leaves the source account, in minor units.
targetAmountMinorintegerThe amount that enters the target account, in minor units.
requestedSidestringWhich amount you gave: SOURCE or TARGET. The bank calculates the other one.
appliedRatestringThe rate that will apply, as a decimal string.
taxAmountMinorinteger | nullThe tax on the exchange, when the bank reports it.
expiresAtstring | nullWhen the quote expires. Open the order before this time.
validForSecondsintegerSeconds of validity left when the response was sent.
insufficientBalancebooleantrue: the bank warns that the source balance may not be enough.
belowMinimumbooleantrue: the amount is below the bank minimum.
createdAtstring | nullWhen the quote was created.

Opening an order

POST /api/v1/bank-connect/fx/orders

HeaderValue
Idempotency-KeyRequired. 8–200 printable ASCII characters. Same rules as transfers. → Money Transfers › Idempotency-Key
Body fieldTypeRequiredDescription
quoteIdstring (UUID)YesThe id of a quote that has not expired and has not been used.
descriptionstringNoDescription, 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, returns 404 bank_connect.fx.order_not_found.
  • GET /api/v1/bank-connect/fx/orders lists 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.

FieldTypeDescription
idstring (UUID)The order's id.
quoteIdstring (UUID)The order's quote.
connectionId, sourceAccountId, targetAccountIdstring (UUID)The connection and the two accounts.
sourceCurrency / targetCurrencystringThe two accounts' currencies.
sourceAmountMinor / targetAmountMinorintegerThe amounts of the quote, in minor units.
appliedRatestringThe applied rate, as a decimal string.
descriptionstring | nullThe description you sent.
idempotencyKeystringThe Idempotency-Key the order was created with.
statusstringSUBMITTED, COMPLETED or FAILED. → Concepts › Currency exchange
failureCodestring | nullReason code of a FAILED order (bank_connect.fx.*). → Error Codes › Exchange result codes
outcomeUnknownbooleantrue: the outcome is not confirmed by the bank yet.
initiatedViastringAPI or PANEL.
sourceBalanceAfterMinor / targetBalanceAfterMinorinteger | nullThe two accounts' balances after the exchange, when the bank reports them.
createdAt, submittedAt, completedAt, failedAtstring | nullTimestamps.
fxSessionIdstring | nullSet only on orders opened in the panel; null on API orders.

Errors

HTTPcodeWhat to do
400VALIDATION_ERRORYou sent an undeclared field or parameter, or a field is malformed.
400bank_connect.validation.invalidBoth amounts or neither were sent, the two accounts are the same, or the description breaks a rule; see details.errors[].
400bank_connect.transfer.idempotency_key_requiredAdd the Idempotency-Key header when opening an order.
403API_INSUFFICIENT_SCOPEAdd the bank-connect:transfer permission to your key.
403bank_connect.auth.ip_allowlist_required / bank_connect.auth.ip_not_allowedSet an IP allowlist, or send the request from a listed address.
404bank_connect.connection.not_found / bank_connect.account.not_foundCheck the connection or account id.
404bank_connect.fx.quote_not_found / bank_connect.fx.order_not_foundCheck the id and your key's environment.
409bank_connect.fx.quote_expired / bank_connect.fx.quote_usedGet a new quote.
409bank_connect.fx.idempotency_conflictYou used this key with another body; use a new key for a new order.
409bank_connect.transfer.account_not_eligibleThe account, connection or bank details are not active right now.
412bank_connect.fx.capability_unavailableThis connection does not support currency exchange.
422bank_connect.fx.accounts_invalidPick two active accounts in different currencies on the same connection.
422bank_connect.fx.pair_unsupportedThe bank does not exchange this currency pair.
422bank_connect.fx.quote_rejectedThe bank gave no quote; the reason is in details.reasonCode.
429RATE_LIMITEDThe request limit was exceeded; wait Retry-After. → Limits