LumaPayAPI v1
UK RU EN
Merchant Integration Reference

Accept and pay out through one consistent contract

A P2P payment system: create payin and payout orders, reconcile statuses, receive webhooks in a single canonical format. All amounts are in major units, 2 decimal places.

BASE URL https://lumapay.org/api/v1

Overview

Every request is signed and goes to a versioned path /api/v1/…. The version lives in the path: when v2 arrives, your v1 integration keeps working unchanged. Within a version we only add fields — we never remove or rename them.

MethodPathPurpose
POST/ordersCreate a payin order, get the requisite for the payer
GET/orders/{uuid}Current state of the order
POST/orders/{uuid}/paidPayer paid an IBAN or quasi order: pass the receipt to the trader (required for iban, optional for quasi)
POST/payoutsCreate a payout to the recipient's card or IBAN
GET/payouts/{uuid}Current state of the payout
POST/payouts/{uuid}/cancelCancel a payout no trader has taken yet
GET/payouts/{uuid}/receipts/{index}Payout receipt file
GET/balanceBalance and turnover broken down by currency

Authentication & signature

Add two headers to every request. The server's verification order is: rate limit → body integrity → key → signature, so the error names its exact cause.

HeaderValue
X-API-KeyYour api_key (the merchant's public identifier)
X-SignatureHMAC-SHA256(<raw request body>, api_secret) in hex
Content-Typeapplication/json

The exact bytes of the body you send are signed — serialize the JSON once and sign that very string. For GET requests the body is empty, so X-Signature is the HMAC of an empty string with api_secret.

Two different secrets — don't mix them up

api_secret signs your requests to us. webhook_secret is what you use to verify our callbacks. Verifying a callback with the wrong secret is the most expensive integration mistake.

Signature example (bash)
# the body we send
BODY='{"order_id":"A-1001","amount":1500.00,"currency":"UAH"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$API_SECRET" | sed 's/^.*= //')

curl -X POST https://lumapay.org/api/v1/orders \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -H "X-Signature: $SIG" \
  -d "$BODY"

Additionally: if an IP allowlist is set in the profile, requests are accepted only from it (REMOTE_ADDR is checked, X-Forwarded-For is ignored).

Idempotency

Orders and payouts are idempotent by the pair (merchant, order_id). An exact repeat of the same request returns the original object (no duplicate is created). The same order_id with a changed body (amount, currency, payer / recipient card) is a 409 idempotency_conflict conflict, and the response contains the existing object.

In practice

Set your own order_id and safely retry network failures with the same request — there will be no duplicate. callback_url is not part of the fingerprint: changing the delivery address and retrying is the same order.

Payin orders

POST/api/v1/orders

Creates a payin order and returns the requisite (card) you show to the payer. The commission is withheld from the amount (inclusive): the merchant is credited amount − commission.

Request parameters
FieldTypeDescription
order_idstringrequiredYour external number. Idempotency key. Up to 255 characters.
amountnumberrequiredAmount in major units, ≥ 0.01, 2 decimals.
currencystring(3)requiredISO currency code. Must be active and enabled for payins.
payer_idstringoptionalPayer identifier. Participates in routing and idempotency.
payer_bankstringoptionalPayer's bank. Used only when the bank-match rule is enabled.
methodstringoptionalcard (default), iban - payment by IBAN transfer, or quasi - payment by link, pay-in only. quasi requires an enabled tariff, otherwise 422 method_not_enabled.
callback_urlstring(url)optionalCallback address for this order. Empty → the address from the dashboard.
Request
{
  "order_id": "A-1001",
  "amount": 1500.00,
  "currency": "UAH",
  "payer_id": "user-42"
}
Response · 201
{
  "id": "7af7c1df-0629-42d4-9068-df6500b3acd6",
  "order_id": "A-1001",
  "status": "awaiting_payment",
  "amount": 1500.00,
  "amount_settled": null,
  "commission": 30.00,
  "currency": "UAH",
  "exchange_rate": 45.58,
  "settlement_currency": "USDT",
  "payer_id": "user-42",
  "requisite": {
    "card_number": "5375 4141 0000 1234",
    "card_holder": "IVAN PETRENKO",
    "bank": "PrivatBank",
    "type": "card"
  },
  "created_at": "2026-08-09T10:00:00+00:00",
  "expires_at": "2026-08-09T10:15:00+00:00",
  "completed_at": null
}

Show requisite.card_number to the payer; wait for the order.completed webhook (not polling). The id field is the {uuid} for the status request.

With "method": "iban" the requisite has type = iban, card_number = null, iban (UA…, 29 chars), card_holder - recipient name, bank_code - EDRPOU/MFO/RNOKPP, payment_purpose - payment reference or null. No free IBAN - 503 no_requisite_available, same as for cards.

With "method": "quasi" the requisite has type = quasi, pay_link - the link the payer pays through, and card_number and card_holder = null. The method is pay-in only and needs an enabled tariff: without it - 422 method_not_enabled.

If a hosted payment page is enabled for the method, the response contains payment_url - a link you can redirect the payer to right away: the page shows the details, the timer and the "I have paid" button. If the field is absent, show requisite on your side. The optional return_url in the request (http/https, up to 255 characters) is the address of the button that returns the payer to your site.

GET/api/v1/orders/{uuid}

Returns the current state of the order (the same object as POST /orders). Another merchant's order is invisible even with a known UUID.

Order statuses
awaiting_paymentRequisite issued, waiting for the payer's payment
processingPayment is seen, crediting reconciliation is in progress
completedCredited successfully — order.completed webhook
expiredNot paid in time — order.expired webhook
cancelledCancelled by an operator — order.cancelled webhook
disputedA dispute is open; the resolution will close the order — order.disputed webhook

If the order has a dispute, the response carries a dispute field - the same object POST /orders/{uuid}/dispute returns, for the latest dispute of the order. No dispute - no field.

POST/api/v1/orders/{uuid}/cancel

Cancel an order that has not been paid yet: the payer changed their mind, or an operator closes it on your side. The trader card is freed for the next order immediately. The response carries the same order object with status cancelled, and an order.cancelled event is sent to your callback_url.

The body is optional. You may pass reason — it goes into the webhook, the audit log and the order card, where a payment-system operator reads it, so write a human-readable text rather than an internal code; an empty body {} is accepted too, and «Cancelled by merchant» is recorded. Sign exactly the string you send.

Only unpaid orders can be cancelled — statuses pending and awaiting_payment. Once the payer has sent the money (processing) or a dispute is open, API cancellation no longer works: funds may already have reached the trader card, and support handles such cases. The response is 409 order_not_cancellable with the current order state in the order field.

POST/api/v1/orders/{uuid}/paid

For method=iban and method=quasi orders only. The payer made the transfer and pressed "I have paid" in your form: send us the receipt and we show it to the trader. For quasi the receipt is optional: the request is accepted without receipts, then the trader confirms the payment. The order moves to processing and waits for the trader to confirm; the expires_at timer no longer applies. The outcome arrives as the usual order.completed or order.disputed webhook.

Request fields
FieldTypeDescription
receiptsarrayyes, for quasi - no1 to 3 files, each a base64 string (a data:...;base64, prefix is fine). JPG, PNG or PDF, up to 5 MB each
{"receipts": ["iVBORw0KGgoAAAANSUhEUgAA..."]}

A repeated call adds receipts to those already sent, at most 3 per order in total. The response is the order object with payer_paid_at and receipts_count.

Errors: 422 method_not_supported - neither an IBAN nor a quasi order; 422 receipt_invalid, receipt_type_not_supported, receipt_too_large, receipt_limit - file rejected; 409 order_not_active - the order is already closed, expired or under dispute (current state in the order field).

POST/api/v1/orders/{uuid}/dispute

The payer paid, but the order was not credited: it expired, was credited for a smaller amount or is still in progress. Send the payer's receipt and a dispute is opened, the same as through the Telegram bot or the cabinet. Works for orders of any method and status. Instead of our uuid the path may carry your order_id.

The dispute then goes to the trader. Once confirmed, the order is credited automatically or after a support check; the money arrives as the usual order.completed or order.amount_corrected webhook. The dispute decision is the dispute.resolved webhook.

Request fields
FieldTypeDescription
receiptsarrayyes1 to 3 files, each a base64 string (a data:...;base64, prefix is fine). JPG, PNG or PDF (bank PDFs with an electronic signature too), up to 5 MB each
commentstringnoUp to 2000 characters: what happened, payment amount and time. Seen by support and the trader
{"receipts": ["JVBERi0xLjQK..."], "comment": "Paid 1500 at 14:05, the order expired"}

The response is 201 when a dispute is opened and 200 when the order already has an open dispute: new receipts are added to it. The same receipt is never added twice, so the request is safe to retry after a timeout; once the dispute is resolved, a retry with the same receipts returns it (200), while a new receipt opens a new dispute.

{
  "dispute": {
    "id": 731,
    "number": "100731",
    "status": "open",
    "outcome": null,
    "created_at": "2026-10-18T14:20:11+00:00",
    "resolved_at": null,
    "already_credited": false
  },
  "order": { ... }
}
statusopen - under review, resolved - decided
outcomemerchant - credited in full, partial - credited for a different amount, trader - payment not confirmed; null while the dispute is open
already_creditedtrue - the order is already credited in full; the dispute is opened anyway (it may be a second payment) and support sees the flag

Errors: 422 receipt_invalid, receipt_type_not_supported, receipt_too_large, receipt_limit - file rejected; 409 dispute_not_available - a sandbox test order.

Payouts

POST/api/v1/payouts

Creates a payout to the recipient's card or IBAN. The commission is added on top (on-top): the merchant is charged amount + commission = amount_charged.

Request parameters
FieldTypeDescription
order_idstringrequiredYour external payout number. Idempotency key.
amountnumberrequiredTransfer amount, ≥ 0.01, 2 decimals.
currencystring(3)requiredISO code. Active and enabled for payouts.
methodstringoptionalcard (default) or iban - transfer to an IBAN account. An IBAN payout is executed and confirmed by the trader manually, same as a card payout.
recipient_cardstringrequiredRecipient's card number (up to 32). Not needed for method=iban.
recipient_namestringoptionalRecipient's name. Participates in idempotency. Required for method=iban: full name or company name.
recipient_bankstringoptionalRecipient's bank.
recipient_ibanstringoptionalRecipient's IBAN, only for method=iban. Format and checksum are validated; spaces are allowed. A Ukrainian IBAN is 29 characters.
recipient_tax_idstringoptionalRecipient's EDRPOU (8 digits) or individual tax number (10 digits), only for method=iban.
payment_purposestringoptionalPayment purpose, up to 140 characters, only for method=iban.
callback_urlstring(url)optionalCallback address for this payout.
Response · 201
{
  "id": "b1e2c3d4-5678-90ab-cdef-1234567890ab",
  "order_id": "P-2001",
  "status": "pending",
  "amount": 1000.00,
  "commission": 15.00,
  "amount_charged": 1015.00,
  "currency": "UAH",
  "exchange_rate": 45.58,
  "settlement_currency": "USDT",
  "method": "card",
  "recipient": { "card_number": "4149 •••• •••• 3179", "name": "IVAN P.", "bank": "A-Bank" },
  "receipt_attached": false,
  "receipts": [],
  "created_at": "2026-08-09T10:00:00+00:00",
  "expires_at": "2026-08-09T14:00:00+00:00",
  "completed_at": null
}

For method=iban the response has method: "iban", and recipient contains iban (masked), tax_id and payment_purpose, while card_number is null. The commission is the IBAN payout rate from your tariff; if no separate rate is set, the card payout rate applies. IBAN, tax number and purpose participate in idempotency.

GET/api/v1/payouts/{uuid}

Payout state (the same object as at creation), scoped to your merchant.

Payout statuses
pendingIn the shared pool, waiting for a trader
assigned / processingTaken into work — payout.processing webhook
completedDone — payout.completed webhook, has tx_receipt
expiredTime window exhausted
cancelledCancelled
disputedA dispute is open
GET/api/v1/payouts/{uuid}/receipts/{index}

The payout receipt file, its type is in the Content-Type header. Ready links are in the receipts field of the payout object and of the webhook: a payout can have up to 5 receipts, {index} is the number from the link. Sign the request like any GET: X-API-Key and X-Signature of the empty body. Receipts are available in the processing, completed and disputed statuses.

Errors: 404 payout_not_found - no such payout or it is not yours; 404 receipt_not_found - no receipt with this index.

POST/api/v1/payouts/{uuid}/cancel

Withdraw a payout while no trader has taken it. The body is empty — send {} and sign exactly those two bytes. The response is the same payout object with status cancelled, and a payout.cancelled event goes to your callback_url.

Once a trader has taken the payout, the API will not cancel it: the transfer may already be under way. Support closes such cases — the answer is 409 payout_not_cancellable with the current payout state in the payout field.

Balance

GET/api/v1/balance

Balance and turnover broken down by currency, computed the same way as in the dashboard: settled − paid out − hold on open payouts − withdrawal requests + adjustments (requests in reserving statuses; adjustments are manual corrections made by an administrator, the adjustments field).

Response · 200
{
  "merchant_id": 1,
  "balances": [
    {
      "currency": "UAH",
      "balance": 598229.84,
      "settled": 6449109.84,
      "paid_out": 5849865.00,
      "held": 1015.00,
      "withdrawn": 0.00,
      "adjustments": 0.00,
      "turnover": 7010100.00,
      "commission": 560792.16,
      "orders": 438,
      "payouts": 56,
      "withdrawals": 0
    }
  ],
  "generated_at": "2026-08-09T10:15:00+00:00"
}
held - funds locked by open payouts

A created payout locks the amount with commission at once: it moves to held and lowers balance. Completion moves it to paid_out, cancellation or expiry returns it to the available funds.

Webhooks

We send a POST to your delivery address on key transitions — order completion and expiry, amount correction, and payout changes — rather than literally on every status change. Respond with 2xx quickly; process the body idempotently.

Delivery headers
Content-Typeapplication/json
X-EventEvent type, e.g. order.completed — you can route handlers without parsing the body
X-SignatureHMAC-SHA256(<raw body>, webhook_secret) — verify before processing
Signature verification

Compute the HMAC over the raw bytes of the body (before JSON parsing) and the webhook_secret — not api_secret. Compare in constant time.

Order events

The canonical payload is the same for all order.* events: fixed field order and names.

EventWhen it is sent
order.completedThe order was closed successfully — credit it as a deposit
order.amount_correctedThe amount of an already completed order was corrected. Arrives instead of a repeated order.completed
order.expiredThe order was not paid in time
order.cancelledOrder was cancelled by an operator, the hold was released. Extra field: reason
order.disputedA dispute was opened for the order, the final status is not yet decided. Extra field: dispute_id
dispute.resolvedDecision on a dispute opened through the API. The order payload with its current status and a dispute field. Do not credit money on it - use order.completed or order.amount_corrected. If support changes the decision, the event comes again with the new outcome

A manual resend from the admin panel sends the same payload with an extra flag "resent": true.

Canonical body · order.completed
{
  "event": "order.completed",
  "order_id": "7af7c1df-0629-42d4-9068-df6500b3acd6", // our uuid
  "merchant_order_id": "A-1001",                      // your number
  "status": "completed",
  "currency": "UAH",
  "amount": 1500.00,                                  // to pay (does not change)
  "amount_requested": 1500.00,                        // what you requested
  "amount_received": 1500.00,                         // actually received
  "amount_settled": 1470.00,                          // to be credited (minus commission)
  "exchange_rate": 45.58,
  "settlement_currency": "USDT"
}
Key amounts

Credit the player/customer by amount_received (actually received), and amount is the amount to pay, it never changes. It may differ by a few UAH from the requested one (amount_requested): the provider makes the amount unique to match the payment. Show the payer exactly the amount from the order creation response. amount_settled is what will land on your balance after commission.

Extended body · order.amount_corrected
{
  "event": "order.amount_corrected",
  "order_id": "7af7c1df-0629-42d4-9068-df6500b3acd6",
  "merchant_order_id": "A-1001",
  "status": "completed",
  "currency": "UAH",
  "amount": 1500.00,
  "amount_received": 1400.00,
  "amount_settled": 1372.00,
  "exchange_rate": 45.58,
  "settlement_currency": "USDT",
  "correction_id": 42,
  "reason": "partial_payment",
  "amount_original": 1500.00, // amount before correction
  "amount_corrected": 1400.00 // new amount
}
Why a separate event

If the amount is corrected after the order was already completed (and you already received order.completed), a repeated order.completed would cause a double credit. That's why order.amount_corrected arrives — update the amount, don't credit it a second time.

Payout events

EventWhen
payout.processingThe payout was taken into work
payout.completedThe payout was executed (has tx_receipt)
payout.expiredThe payout's time window is exhausted
payout.cancelledThe payout was cancelled
payout.disputedA dispute was opened on the payout
Payout webhook body
{
  "event": "payout.completed",
  "payout_id": "b1e2c3d4-5678-90ab-cdef-1234567890ab", // our uuid
  "order_id": "P-2001",                                // YOUR external number
  "amount": 1000.00,
  "amount_charged": 1015.00,                           // charged to the merchant
  "currency": "UAH",
  "exchange_rate": 45.58,
  "settlement_currency": "USDT",
  "status": "completed",
  "tx_receipt": "https://.../api/v1/payouts/b1e2c3d4-5678-90ab-cdef-1234567890ab/receipts/0", // first receipt
  "receipts": ["https://.../api/v1/payouts/b1e2c3d4-5678-90ab-cdef-1234567890ab/receipts/0"],
  "completed_at": "2026-08-09T10:15:00+00:00"
}
Order exchange rate

exchange_rate is the settlement_currency rate against the document currency, fixed when the order or payout is created and never recalculated. Use it for your own average rate. Older documents without a rate return null.

order_id semantics differ

In payout events order_id is your external number, and the payout's uuid is in payout_id. In order events it's the opposite: order_id is our uuid, and your number is merchant_order_id. Map carefully.

Delivery & retries

Delivery is asynchronous via a queue. Success is any 2xx. A non-2xx or timeout (connect 5s / read 15s) schedules a retry with increasing delay.

Attempt12345+
Delay30s60s5 min15 min1 h

Up to 5 attempts; after they are exhausted the callback is marked failed. Process idempotently by order_id/payout_id — the same callback may arrive again.

Error codes

All errors are JSON with an error field (machine code) and message. Switch on error, not on the text.

HTTPerrorWhen
400empty_bodyEmpty body in a POST
400malformed_jsonThe body is not valid JSON
400content_type_missingContent-Type: application/json was not sent
401api_key_missingNo X-API-Key header
401api_key_invalidThe key does not match any merchant
401signature_missingNo X-Signature header
401signature_invalidThe signature did not match the body's HMAC
403merchant_suspendedThe merchant is suspended/blocked
403ip_not_allowedThe IP is not on the allowlist
404order_not_foundOrder not found (or belongs to someone else)
404payout_not_foundPayout not found (or belongs to someone else)
404unsupported_api_versionPath without a version or an unknown version
404endpoint_not_foundThe version exists, the path does not (the response lists the available ones)
405method_not_allowedThe path exists, but not for this method
409idempotency_conflictThe same order_id with a changed body
409dispute_not_availableA sandbox test order cannot be disputed
422validation_failedFields failed validation (details are in details)
422amount_out_of_rangeAmount outside min/max (present in the body)
422currency_not_supportedThe currency is not active on the platform
422currency_not_enabledPayins in this currency are not enabled for the merchant
422payout_not_enabledPayouts in this currency are not enabled
422insufficient_balanceMerchant balance plus the overdraft limit does not cover the payout with commission. Body carries required, balance, overdraft_limit
429merchant_limitDaily amount or count limit
429—Rate limit: 600 requests per minute per key. The response is the server default and carries no error field — check the 429 status before parsing the body. The Retry-After header tells you how long to wait
503no_requisite_availableNo free requisite available (has Retry-After: 5)
503rate_unavailableNo exchange rate configured for the currency - order or payout not created
503accepting_suspendedKill-switch blocks payins
503payouts_suspendedKill-switch blocks payouts

LumaPay · Merchant API v1 · all amounts in major units (2 decimals), times in ISO 8601 (UTC). Secrets are managed in the merchant dashboard → "Integration".