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 | /payouts | Create a payout to the recipient's card |
| GET | /payouts/{uuid} | Current state of the payout |
| POST | /payouts/{uuid}/cancel | Cancel a payout no trader has taken yet |
| 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://yuppipay.net/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. |
| 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.
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 |
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.
Payouts
Creates a payout to the recipient's card. 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. |
| recipient_card | string | required | Recipient's card number (up to 32). |
| recipient_name | string | optional | Recipient's name. Participates in idempotency. |
| recipient_bank | string | optional | Recipient's bank. |
| 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",
"recipient": { "card_number": "4149 •••• •••• 3179", "name": "IVAN P.", "bank": "A-Bank" },
"receipt_attached": false,
"created_at": "2026-08-09T10:00:00+00:00",
"expires_at": "2026-08-09T14:00:00+00:00",
"completed_at": null
}
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 |
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 |
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, // declared (does not change)
"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 initial order, it never changes. 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://.../receipt.jpg",
"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 |
| 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 |
YuppiPay · Merchant API v1 · all amounts in major units (2 decimals), times in ISO 8601 (UTC). Secrets are managed in the merchant dashboard → "Integration".