Bank Accounts
API Reference
Money Transfers

Money Transfers

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 transfers, you can send money from your connected bank account to an IBAN. Whether a connection supports transfers is in the capabilities field of the Connections endpoint: when the list includes OUTGOING_TRANSFER, transfers can be made from that connection's accounts.

A transfer sent with an API key needs no e-mail verification code. The approval is your key's permission, the request signature and the request coming from an address on the key's IP allowlist. The transfer becomes SUBMITTED as soon as it is created and is queued for the bank. The bank decides the route (between your own accounts, within the same bank, FAST or EFT).

⚠️

An IP allowlist is required. The bank-connect:transfer permission can only be given to an API key with an IP allowlist, and the request must come from an address on that list. A key with an empty list gets 403 bank_connect.auth.ip_allowlist_required; a request from an address not on the list gets 403 bank_connect.auth.ip_not_allowed. The rule applies to every endpoint on this page, reads included. → API Introduction › IP allowlist

Endpoints

MethodPathDescription
GET/transfer-purposes?connectionId=…Payment purposes the bank accepts
POST/transfersCreates a transfer. Idempotency-Key required.
GET/transfersLists transfers
GET/transfers/{transferId}A single transfer

Every endpoint needs bank-connect:transfer. Your key decides the environment; do not send environment.

Signing a request with a body

Reads are signed as described in API Introduction. On POST requests, the SHA-256 digest of the body is sent in the Digest header and becomes the last line of the canonical string. The cURL examples on this page use the shell function below:

# lunixi-sign.sh — Usage: source lunixi-sign.sh; lunixi_sign POST "/api/v1/bank-connect/transfers" "$BODY"
LUNIXI_KEY_ID="mk_test_..."
LUNIXI_KEY_FILE="lunixi-signing.key"
 
lunixi_sign() {
  local method="$1" req_path="$2" body="$3"
  DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
  NONCE=$(openssl rand -hex 16)
  if [ -n "$body" ]; then
    DIGEST="SHA-256=$(printf '%s' "$body" | openssl dgst -sha256 -binary | base64)"
    printf '%s\n%s\nX-Date:%s\nX-Nonce:%s\nDigest:%s' "$method" "$req_path" "$DATE" "$NONCE" "$DIGEST" > canonical.txt
  else
    DIGEST=""
    printf '%s\n%s\nX-Date:%s\nX-Nonce:%s' "$method" "$req_path" "$DATE" "$NONCE" > canonical.txt
  fi
  SIGNATURE=$(openssl pkeyutl -sign -inkey "$LUNIXI_KEY_FILE" -rawin -in canonical.txt | base64 | tr -d '\n')
}

Send the body as single-line (compact) JSON and compute the digest over exactly the text you send. Rules: Authentication › The Digest header format.

The PHP examples use PHP SDK 0.22.0 or later; $bankConnect->transfers() calls the endpoints on this page and signs every request.

Payment purposes

GET /api/v1/bank-connect/transfer-purposes?connectionId={connectionId}

Returns the payment purpose codes the bank accepts on this connection. You can put one of these codes in the transfer's paymentPurpose; without it the bank's default is used.

{
  "status": "success",
  "code": "SUCCESS",
  "data": {
    "items": [
      { "code": "OTHER", "name": "Diğer", "order": 1 },
      { "code": "RENT", "name": "Kira", "order": 2 }
    ]
  },
  "statusCode": 200,
  "requestId": "00000000-0000-0000-0000-000000000000"
}

Codes differ between banks; read them from this endpoint instead of hard-coding them.

Creating a transfer

POST /api/v1/bank-connect/transfers

HeaderValue
Idempotency-KeyRequired. 8–200 printable ASCII characters (no spaces). → Idempotency-Key
Content-Typeapplication/json
Body fieldTypeRequiredDescription
accountIdstring (UUID)YesThe account the money leaves from. Its connection must support OUTGOING_TRANSFER.
receiverIbanstringYesThe receiver's TR IBAN, with or without spaces; the check digits are validated.
receiverNamestringYesThe receiver's name or company title, 2–140 characters.
amountMinorintegerYesAmount in minor units (kuruş), an integer greater than zero: 150075 = ₺1,500.75.
currencystringNoISO 4217. The default and the only value supported today is TRY.
paymentPurposestringNoA code from the payment purposes endpoint.
descriptionstringNoDescription, at most 140 characters.

Any other body field (environment, merchantId …) gets 400 VALIDATION_ERROR.

source lunixi-sign.sh
REQ_PATH="/api/v1/bank-connect/transfers"
BODY='{"accountId":"00000000-0000-0000-0000-000000000100","receiverIban":"TR000000000000000000000000","receiverName":"Örnek Tedarik A.Ş.","amountMinor":150075,"description":"Invoice 2026-0042"}'
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: payout-2026-0042" \
  --data-raw "$BODY"

The IBAN in the example is a placeholder; send an IBAN with valid check digits.

A successful response is 201:

{
  "status": "success",
  "code": "SUCCESS",
  "data": {
    "transfer": {
      "id": "00000000-0000-0000-0000-000000002000",
      "connectionId": "00000000-0000-0000-0000-000000000010",
      "accountId": "00000000-0000-0000-0000-000000000100",
      "environment": "live",
      "status": "SUBMITTED",
      "rail": null,
      "amountMinor": 150075,
      "currency": "TRY",
      "sourceIbanMasked": "TR00 **** **** **** **** **00 00",
      "receiverIbanMasked": "TR00 **** **** **** **** **00 00",
      "receiverNameMasked": "Ö**** T****** A.Ş.",
      "receiverBankCode": "0000",
      "paymentPurpose": null,
      "description": "Invoice 2026-0042",
      "idempotencyKey": "payout-2026-0042",
      "failureCode": null,
      "outcomeUnknown": false,
      "initiatedVia": "API",
      "sanctionStatus": "NOT_SCREENED",
      "bankTransactionId": null,
      "bankState": null,
      "createdAt": "2026-01-01T07:00:00.000Z",
      "submittedAt": "2026-01-01T07:00:00.000Z",
      "completedAt": null,
      "failedAt": null,
      "returnedAt": null,
      "canceledAt": null,
      "otpExpiresAt": null
    },
    "stepUp": null,
    "replayed": false
  },
  "statusCode": 201,
  "requestId": "00000000-0000-0000-0000-000000000000"
}
FieldDescription
transferThe transfer; fields below.
stepUpAlways null for requests made with an API key (there is no verification code).
replayedtrue: a transfer created earlier with the same Idempotency-Key and the same body was returned; no new transfer was opened.

Lunixi does not check the balance; the bank decides whether it is enough. If it is not, the transfer becomes FAILED with failureCode bank_connect.transfer.insufficient_balance.

Idempotency-Key

Every transfer request carries an Idempotency-Key so that the same transfer is never sent twice. The key is unique within your organization and environment.

CaseResult
New keyA new transfer is created, replayed: false.
Same key, same bodyNo new transfer; the existing transfer is returned with replayed: true.
Same key, different body409 bank_connect.transfer.idempotency_conflict.
No header400 bank_connect.transfer.idempotency_key_required.
Malformed key400 bank_connect.transfer.idempotency_key_invalid.
  • Derive the key from the payment record in your own system (for example payout-<your payment id>). Then a restart of your process still sends the same key for the same payment.
  • On a timeout or a network error, resend the request with the same key and the same body. If the transfer was created, the existing one is returned; if not, it is created now.
  • The SDKs generate a key when you do not pass one and reuse it on their own retries. The key used is in transfer.idempotencyKey in the response.

Tracking a transfer

The outcome of a transfer arrives in two ways:

  • Webhook: the bank_transfer.created, bank_transfer.completed, bank_transfer.failed and bank_transfer.returned events. → Webhook Events › Transfer events
  • Query: GET /api/v1/bank-connect/transfers/{transferId}. If you missed a webhook or have no endpoint, track the transfer with this endpoint.

What the statuses mean and how they change: Concepts › Transfer statuses.

Single transfer

GET /api/v1/bank-connect/transfers/{transferId} returns one transfer; data is the transfer object itself. An unknown transfer, or one that belongs to another organization or the other environment, returns 404 bank_connect.transfer.not_found.

source lunixi-sign.sh
REQ_PATH="/api/v1/bank-connect/transfers/00000000-0000-0000-0000-000000002000"
lunixi_sign GET "$REQ_PATH"
 
curl "https://api-gateway.lunixi.com$REQ_PATH" \
  -H "X-Key-Id: $LUNIXI_KEY_ID" \
  -H "X-Date: $DATE" \
  -H "X-Nonce: $NONCE" \
  -H "X-Signature: $SIGNATURE"

Listing transfers

GET /api/v1/bank-connect/transfers lists transfers newest first. Transfers started in the panel and through the API are listed together (initiatedVia).

Query parameterTypeDescription
accountIdstring (UUID)Only transfers from this account.
statusstringComma-separated statuses, e.g. COMPLETED,RETURNED.
createdFromstringISO 8601, inclusive.
createdTostringISO 8601, exclusive.
limitintegerRecords per page, 1–100. Default 25.
cursorstringpageInfo.nextCursor from the previous response.

Paging rules are the same as on the read endpoints. → API Introduction › Pagination

Transfer with an unconfirmed outcome

When no definite answer comes from the bank (for example the bank did not reply in time), the transfer stays SUBMITTED and outcomeUnknown becomes true. In that case:

  • Do not send the transfer again. The money may have left at the bank; a new transfer could pay twice.
  • Lunixi confirms the outcome with the bank. When the transfer is settled its status becomes COMPLETED or FAILED and the matching webhook is sent.
  • If the bank check shows that the transfer did not take place, failureCode is bank_connect.transfer.failed_after_bank_check. You can then send the same payment again with a new Idempotency-Key.
Transfer with an unconfirmed outcomeFlow: when the bank gives no definite answer, the transfer stays SUBMITTED with outcomeUnknown true and must not be sent again; Lunixi checks with the bank and settles it as COMPLETED or FAILED with failed_after_bank_check, after which you may send the payment again with a new Idempotency-Key.NO ANSWERBANK CHECKOPTIONALTransfer sentPOST …/transfersSUBMITTEDoutcomeUnknown: trueLunixi checkswith the bankCOMPLETEDbank_transfer.completedFAILEDfailed_after_bank_checkSend again witha new keyIdempotency-Keydo not send it againLEGENDSTEPWAITING — NOT A FAILURESETTLEDYOUR CHOICE
The money may already have left at the bank, so a new transfer could pay twice. Send the payment again only after FAILED with failed_after_bank_check, and with a new Idempotency-Key.

The transfer object

In transfer, exchange quote and exchange order responses, a field without a value is returned as null; the field is not left out. This differs from the "a field without a value is left out" rule of the read endpoints.

FieldTypeDescription
idstring (UUID)The transfer's id.
connectionIdstring (UUID)The account's connection.
accountIdstring (UUID)The account the money left from.
environmentstringtest or live.
statusstringThe transfer's status. → Transfer statuses
railstring | nullThe route the bank chose: INTERNAL, INTRABANK, FAST, EFT or UNKNOWN. null until the bank says.
amountMinorintegerAmount in minor units.
currencystringCurrency (ISO 4217).
sourceIbanMaskedstring | nullThe source account's masked IBAN.
receiverIbanMaskedstringThe receiver's masked IBAN.
receiverNameMaskedstringThe receiver's masked name.
receiverBankCodestring | nullThe receiving bank's EFT code.
paymentPurposestring | nullThe payment purpose code you sent.
descriptionstring | nullThe description you sent.
idempotencyKeystringThe Idempotency-Key the transfer was created with.
failureCodestring | nullReason code of a FAILED, RETURNED or CANCELED transfer (bank_connect.transfer.*). → Error Codes › Transfer result codes
outcomeUnknownbooleantrue: the outcome is not confirmed by the bank yet. → Unconfirmed outcome
initiatedViastringAPI or PANEL.
sanctionStatusstringReceiver screening status: NOT_SCREENED, CLEAR, POTENTIAL_MATCH, ERROR. Informational; it never stops a transfer.
bankTransactionIdstring | nullOnce the transfer's outgoing transaction is found on your account, that transaction's transactionId. → Transactions
bankStatestring | nullThe status text the bank reported for the transfer; informational only, write your code against status.
createdAtstring | nullWhen it was created.
submittedAtstring | nullWhen it was approved and queued for the bank.
completedAtstring | nullWhen it completed.
failedAtstring | nullWhen it failed.
returnedAtstring | nullWhen it was returned.
canceledAtstring | nullWhen it was canceled.
otpExpiresAtstring | nullOnly for a transfer started in the panel and waiting for the verification code: when the code expires.

IBANs and the receiver name are returned masked only; the full IBAN and full name are never in a response. → Concepts › Masking

Errors

HTTPcodeWhat to do
400VALIDATION_ERRORYou sent an undeclared field or parameter, or a field is malformed.
400bank_connect.validation.invalidThe IBAN, receiver name, amount, description or payment purpose breaks a rule; see details.errors[].
400bank_connect.transfer.idempotency_key_requiredAdd the Idempotency-Key header.
400bank_connect.transfer.idempotency_key_invalidUse 8–200 printable ASCII characters.
403API_INSUFFICIENT_SCOPEAdd the bank-connect:transfer permission to your key.
403bank_connect.auth.ip_allowlist_requiredSet an IP allowlist on your key.
403bank_connect.auth.ip_not_allowedSend the request from an allowlisted IP, or update the list.
404bank_connect.transfer.not_foundCheck the id and your key's environment.
404bank_connect.account.not_foundCheck the account id.
409bank_connect.transfer.account_not_eligibleThe account, connection or bank details are not active right now.
409bank_connect.transfer.idempotency_conflictYou used this key with another body; use a new key for a new transfer.
412bank_connect.transfer.capability_unavailableThis connection does not support transfers.
422bank_connect.transfer.currency_unsupportedSend TRY or leave currency out.
429RATE_LIMITEDThe transfer request limit was exceeded; wait Retry-After. → Limits
503bank_connect.auth.ip_allowlist_unavailableRetry the same request with backoff.

If the bank rejects the transfer, the request still answers 201; the transfer becomes FAILED later and the reason is in failureCode. → Error Codes › Transfer result codes