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.
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.
| Method | Path | Purpose |
|---|---|---|
| POST | /orders | Create a payin order, get the requisite for the payer |
| GET | /orders/{uuid} | Current state of the order |
| POST | /orders/{uuid}/paid | Payer paid an IBAN or quasi order: pass the receipt to the trader (required for iban, optional for quasi) |
| POST | /payouts | Create a payout to the recipient's card or IBAN |
| GET | /payouts/{uuid} | Current state of the payout |
| POST | /payouts/{uuid}/cancel | Cancel a payout no trader has taken yet |
| GET | /payouts/{uuid}/receipts/{index} | Payout receipt file |
| GET | /balance | Balance 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.
| Header | Value |
|---|---|
| X-API-Key | Your api_key (the merchant's public identifier) |
| X-Signature | HMAC-SHA256(<raw request body>, api_secret) in hex |
| Content-Type | application/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.
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.
# 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.
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
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.
| Field | Type | Description | |
|---|---|---|---|
| order_id | string | required | Your external number. Idempotency key. Up to 255 characters. |
| amount | number | required | Amount in major units, ≥ 0.01, 2 decimals. |
| currency | string(3) | required | ISO currency code. Must be active and enabled for payins. |
| payer_id | string | optional | Payer identifier. Participates in routing and idempotency. |
| payer_bank | string | optional | Payer's bank. Used only when the bank-match rule is enabled. |
| method | string | optional | card (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_url | string(url) | optional | Callback address for this order. Empty → the address from the dashboard. |
{
"order_id": "A-1001",
"amount": 1500.00,
"currency": "UAH",
"payer_id": "user-42"
}
{
"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.
Returns the current state of the order (the same object as POST /orders). Another merchant's order is invisible even with a known UUID.
| awaiting_payment | Requisite issued, waiting for the payer's payment |
| processing | Payment is seen, crediting reconciliation is in progress |
| completed | Credited successfully — order.completed webhook |
| expired | Not paid in time — order.expired webhook |
| cancelled | Cancelled by an operator — order.cancelled webhook |
| disputed | A 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.
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.
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.
| Field | Type | Description | |
|---|---|---|---|
| receipts | array | yes, for quasi - no | 1 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).
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.
| Field | Type | Description | |
|---|---|---|---|
| receipts | array | yes | 1 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 |
| comment | string | no | Up 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": { ... }
}
| status | open - under review, resolved - decided |
| outcome | merchant - credited in full, partial - credited for a different amount, trader - payment not confirmed; null while the dispute is open |
| already_credited | true - 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
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.
| Field | Type | Description | |
|---|---|---|---|
| order_id | string | required | Your external payout number. Idempotency key. |
| amount | number | required | Transfer amount, ≥ 0.01, 2 decimals. |
| currency | string(3) | required | ISO code. Active and enabled for payouts. |
| method | string | optional | card (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_card | string | required | Recipient's card number (up to 32). Not needed for method=iban. |
| recipient_name | string | optional | Recipient's name. Participates in idempotency. Required for method=iban: full name or company name. |
| recipient_bank | string | optional | Recipient's bank. |
| recipient_iban | string | optional | Recipient's IBAN, only for method=iban. Format and checksum are validated; spaces are allowed. A Ukrainian IBAN is 29 characters. |
| recipient_tax_id | string | optional | Recipient's EDRPOU (8 digits) or individual tax number (10 digits), only for method=iban. |
| payment_purpose | string | optional | Payment purpose, up to 140 characters, only for method=iban. |
| callback_url | string(url) | optional | Callback address for this payout. |
{
"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.
Payout state (the same object as at creation), scoped to your merchant.
| pending | In the shared pool, waiting for a trader |
| assigned / processing | Taken into work — payout.processing webhook |
| completed | Done — payout.completed webhook, has tx_receipt |
| expired | Time window exhausted |
| cancelled | Cancelled |
| disputed | A dispute is open |
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.
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
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).
{
"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"
}
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.
| Content-Type | application/json |
| X-Event | Event type, e.g. order.completed — you can route handlers without parsing the body |
| X-Signature | HMAC-SHA256(<raw body>, webhook_secret) — verify before processing |
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.
| Event | When it is sent |
|---|---|
| order.completed | The order was closed successfully — credit it as a deposit |
| order.amount_corrected | The amount of an already completed order was corrected. Arrives instead of a repeated order.completed |
| order.expired | The order was not paid in time |
| order.cancelled | Order was cancelled by an operator, the hold was released. Extra field: reason |
| order.disputed | A dispute was opened for the order, the final status is not yet decided. Extra field: dispute_id |
| dispute.resolved | Decision 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.
{
"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"
}
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.
{
"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
}
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
| Event | When |
|---|---|
| payout.processing | The payout was taken into work |
| payout.completed | The payout was executed (has tx_receipt) |
| payout.expired | The payout's time window is exhausted |
| payout.cancelled | The payout was cancelled |
| payout.disputed | A dispute was opened on the payout |
{
"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"
}
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.
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.
| Attempt | 1 | 2 | 3 | 4 | 5+ |
|---|---|---|---|---|---|
| Delay | 30s | 60s | 5 min | 15 min | 1 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.
| HTTP | error | When |
|---|---|---|
| 400 | empty_body | Empty body in a POST |
| 400 | malformed_json | The body is not valid JSON |
| 400 | content_type_missing | Content-Type: application/json was not sent |
| 401 | api_key_missing | No X-API-Key header |
| 401 | api_key_invalid | The key does not match any merchant |
| 401 | signature_missing | No X-Signature header |
| 401 | signature_invalid | The signature did not match the body's HMAC |
| 403 | merchant_suspended | The merchant is suspended/blocked |
| 403 | ip_not_allowed | The IP is not on the allowlist |
| 404 | order_not_found | Order not found (or belongs to someone else) |
| 404 | payout_not_found | Payout not found (or belongs to someone else) |
| 404 | unsupported_api_version | Path without a version or an unknown version |
| 404 | endpoint_not_found | The version exists, the path does not (the response lists the available ones) |
| 405 | method_not_allowed | The path exists, but not for this method |
| 409 | idempotency_conflict | The same order_id with a changed body |
| 409 | dispute_not_available | A sandbox test order cannot be disputed |
| 422 | validation_failed | Fields failed validation (details are in details) |
| 422 | amount_out_of_range | Amount outside min/max (present in the body) |
| 422 | currency_not_supported | The currency is not active on the platform |
| 422 | currency_not_enabled | Payins in this currency are not enabled for the merchant |
| 422 | payout_not_enabled | Payouts in this currency are not enabled |
| 422 | insufficient_balance | Merchant balance plus the overdraft limit does not cover the payout with commission. Body carries required, balance, overdraft_limit |
| 429 | merchant_limit | Daily 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 |
| 503 | no_requisite_available | No free requisite available (has Retry-After: 5) |
| 503 | rate_unavailable | No exchange rate configured for the currency - order or payout not created |
| 503 | accepting_suspended | Kill-switch blocks payins |
| 503 | payouts_suspended | Kill-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".