Money Transfers
- 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 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
| Method | Path | Description |
|---|---|---|
GET | /transfer-purposes?connectionId=… | Payment purposes the bank accepts |
POST | /transfers | Creates a transfer. Idempotency-Key required. |
GET | /transfers | Lists 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
| Header | Value |
|---|---|
Idempotency-Key | Required. 8–200 printable ASCII characters (no spaces). → Idempotency-Key |
Content-Type | application/json |
| Body field | Type | Required | Description |
|---|---|---|---|
accountId | string (UUID) | Yes | The account the money leaves from. Its connection must support OUTGOING_TRANSFER. |
receiverIban | string | Yes | The receiver's TR IBAN, with or without spaces; the check digits are validated. |
receiverName | string | Yes | The receiver's name or company title, 2–140 characters. |
amountMinor | integer | Yes | Amount in minor units (kuruş), an integer greater than zero: 150075 = ₺1,500.75. |
currency | string | No | ISO 4217. The default and the only value supported today is TRY. |
paymentPurpose | string | No | A code from the payment purposes endpoint. |
description | string | No | Description, 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"
}| Field | Description |
|---|---|
transfer | The transfer; fields below. |
stepUp | Always null for requests made with an API key (there is no verification code). |
replayed | true: 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.
| Case | Result |
|---|---|
| New key | A new transfer is created, replayed: false. |
| Same key, same body | No new transfer; the existing transfer is returned with replayed: true. |
| Same key, different body | 409 bank_connect.transfer.idempotency_conflict. |
| No header | 400 bank_connect.transfer.idempotency_key_required. |
| Malformed key | 400 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.idempotencyKeyin the response.
Tracking a transfer
The outcome of a transfer arrives in two ways:
- Webhook: the
bank_transfer.created,bank_transfer.completed,bank_transfer.failedandbank_transfer.returnedevents. → 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 parameter | Type | Description |
|---|---|---|
accountId | string (UUID) | Only transfers from this account. |
status | string | Comma-separated statuses, e.g. COMPLETED,RETURNED. |
createdFrom | string | ISO 8601, inclusive. |
createdTo | string | ISO 8601, exclusive. |
limit | integer | Records per page, 1–100. Default 25. |
cursor | string | pageInfo.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
COMPLETEDorFAILEDand the matching webhook is sent. - If the bank check shows that the transfer did not take place,
failureCodeisbank_connect.transfer.failed_after_bank_check. You can then send the same payment again with a newIdempotency-Key.
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.
| Field | Type | Description |
|---|---|---|
id | string (UUID) | The transfer's id. |
connectionId | string (UUID) | The account's connection. |
accountId | string (UUID) | The account the money left from. |
environment | string | test or live. |
status | string | The transfer's status. → Transfer statuses |
rail | string | null | The route the bank chose: INTERNAL, INTRABANK, FAST, EFT or UNKNOWN. null until the bank says. |
amountMinor | integer | Amount in minor units. |
currency | string | Currency (ISO 4217). |
sourceIbanMasked | string | null | The source account's masked IBAN. |
receiverIbanMasked | string | The receiver's masked IBAN. |
receiverNameMasked | string | The receiver's masked name. |
receiverBankCode | string | null | The receiving bank's EFT code. |
paymentPurpose | string | null | The payment purpose code you sent. |
description | string | null | The description you sent. |
idempotencyKey | string | The Idempotency-Key the transfer was created with. |
failureCode | string | null | Reason code of a FAILED, RETURNED or CANCELED transfer (bank_connect.transfer.*). → Error Codes › Transfer result codes |
outcomeUnknown | boolean | true: the outcome is not confirmed by the bank yet. → Unconfirmed outcome |
initiatedVia | string | API or PANEL. |
sanctionStatus | string | Receiver screening status: NOT_SCREENED, CLEAR, POTENTIAL_MATCH, ERROR. Informational; it never stops a transfer. |
bankTransactionId | string | null | Once the transfer's outgoing transaction is found on your account, that transaction's transactionId. → Transactions |
bankState | string | null | The status text the bank reported for the transfer; informational only, write your code against status. |
createdAt | string | null | When it was created. |
submittedAt | string | null | When it was approved and queued for the bank. |
completedAt | string | null | When it completed. |
failedAt | string | null | When it failed. |
returnedAt | string | null | When it was returned. |
canceledAt | string | null | When it was canceled. |
otpExpiresAt | string | null | Only 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
| 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 | The IBAN, receiver name, amount, description or payment purpose breaks a rule; see details.errors[]. |
400 | bank_connect.transfer.idempotency_key_required | Add the Idempotency-Key header. |
400 | bank_connect.transfer.idempotency_key_invalid | Use 8–200 printable ASCII characters. |
403 | API_INSUFFICIENT_SCOPE | Add the bank-connect:transfer permission to your key. |
403 | bank_connect.auth.ip_allowlist_required | Set an IP allowlist on your key. |
403 | bank_connect.auth.ip_not_allowed | Send the request from an allowlisted IP, or update the list. |
404 | bank_connect.transfer.not_found | Check the id and your key's environment. |
404 | bank_connect.account.not_found | Check the account id. |
409 | bank_connect.transfer.account_not_eligible | The account, connection or bank details are not active right now. |
409 | bank_connect.transfer.idempotency_conflict | You used this key with another body; use a new key for a new transfer. |
412 | bank_connect.transfer.capability_unavailable | This connection does not support transfers. |
422 | bank_connect.transfer.currency_unsupported | Send TRY or leave currency out. |
429 | RATE_LIMITED | The transfer request limit was exceeded; wait Retry-After. → Limits |
503 | bank_connect.auth.ip_allowlist_unavailable | Retry 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