Payment API Developer reference

Reference · REST + webhook

Payment API Reference

Create UZS invoices, ingest bank and Telegram events, settle pending invoices, expire stale ones, and deliver signed merchant callbacks. Everything a merchant integration needs is on this page.

Browse endpoints Security checklist
Amount range
1,000–1e9
Body limit
64 KiB
Event window
−10m / +30s
Worker batch
20 events
Invoice lifetime
5 min
Poll interval
5 s
Callback attempts
10
Overload
503 + retry

Start here

Authentication

Use separate credentials per surface. Send them only in headers, only over HTTPS, and never in query strings, logs, or browser bundles.

Merchant API Authorization: Bearer API_KEY
Used by /api/create/ and /api/getter/. One key per merchant; rotate by disabling the old row after clients switch.
Payment webhook X-Secret-Key: PAYMENT_WEBHOOK_SECRET
Used by /api/status/ for trusted payment events.
Cron trigger X-Cron-Key: CRON_SECRET
Used by /api/cron/ to expire invoices and run maintenance.
Telegram webhook X-Telegram-Bot-Api-Secret-Token: TELEGRAM_WEBHOOK_SECRET
Used by /telegram.php for Telegram Business updates.

Endpoints

All examples use https://payments.example.com. Browser cross-origin access is not enabled.

5 endpoints documented

POST

/api/create/

Creates a pending invoice for the authenticated merchant.

Request body
FieldTypeRequiredNotes
amount integer or digit string yes Whole UZS. Range 1,000 to 1,000,000,000. No floats or scientific notation.
Response 200
{
  "status": "success",
  "message": "invoice_created",
  "data": {
    "order_id": "pay_3f9a1c0e7b2d4a58b6c1e0d94f7a2b13",
    "amount": 50137,
    "currency": "UZS",
    "card_number": "8600000000001234",
    "card_holder": "CARD HOLDER",
    "status": "pending",
    "created_at": "2026-10-25 09:00:00",
    "end_time": "2026-10-25 09:05:00",
    "wait_time": 5
  }
}

Ask the customer to pay the returned amount, not the requested one: up to 999 UZS are added so concurrent invoices stay distinguishable. Times are UTC. A payment is matched by amount and card suffix, so a different amount will not settle the invoice.

cURL
curl --fail-with-body --tlsv1.2 \
  -H 'Authorization: Bearer API_KEY' \
  -H 'Content-Type: application/json' \
  --data '{"amount":50000}' \
  https://payments.example.com/api/create/
GET

/api/getter/?order_id=pay_ORDER_ID

Returns one invoice owned by the authenticated merchant. Use it to poll for payment status.

Query parameters
FieldTypeRequiredNotes
order_id string yes Query parameter returned by invoice creation. Format pay_ plus 8–64 letters or digits.
Response 200
{
  "status": "success",
  "message": "order_found",
  "data": {
    "order_id": "pay_3f9a1c0e7b2d4a58b6c1e0d94f7a2b13",
    "amount": 50137,
    "card_number": "8600000000001234",
    "status": "success",
    "created_at": "2026-10-25 09:00:00",
    "end_time": "2026-10-25 09:05:00",
    "currency": "UZS"
  }
}
Invoice status
StatusMeaningClient action
pending Waiting for payment until end_time. Keep polling every wait_time seconds.
success Paid. A paid invoice never becomes expired. Stop polling and fulfil the order.
expired end_time passed without a matching payment. Stop polling. Create a new invoice if the customer still wants to pay.

Polling rules. Poll from your server, never a browser, since it needs the API key. Poll every 5 seconds, about 60 requests per invoice at most. On 429 or 503 wait for Retry-After and continue; do not poll faster to catch up. Prefer signed callbacks and keep polling as the fallback. Fetch the final state once more after end_time.

cURL
curl --fail-with-body --tlsv1.2 \
  -H 'Authorization: Bearer API_KEY' \
  'https://payments.example.com/api/getter/?order_id=pay_ORDER_ID'
POST

/api/status/

Records a trusted payment event and settles a matching pending invoice.

Send with X-Secret-Key: PAYMENT_WEBHOOK_SECRET and Content-Type: application/json.

Request body
FieldTypeRequiredNotes
amountinteger or digit stringyesWhole UZS amount.
card_last_4stringyesFour digit card suffix.
event_idstringyesStable source transaction ID, unchanged across retries.
occurred_atUnix secondsyesWithin ten minutes past and thirty seconds future.
JSON body
{
  "amount": 50000,
  "card_last_4": "1234",
  "event_id": "bank:transaction-123456",
  "occurred_at": 1792100000
}
Response 200
{
  "status": "success",
  "message": "event_recorded",
  "data": {
    "status": "success",
    "order_id": "pay_3f9a1c0e7b2d4a58b6c1e0d94f7a2b13",
    "duplicate": false
  }
}

Matching requires a pending, unexpired invoice that existed when the event occurred. A duplicate returns the recorded outcome with duplicate: true.

POST

/api/cron/

Expires old pending invoices and performs service maintenance. No request body is required.

Schedule with X-Cron-Key: CRON_SECRET, at least once a minute.

cURL
curl --fail-with-body --tlsv1.2 \
  -H 'X-Cron-Key: CRON_SECRET' \
  -X POST \
  https://payments.example.com/api/cron/
POST

/telegram.php

Receives and validates Telegram Business updates.

Register the webhook with Telegram's secret_token set to TELEGRAM_WEBHOOK_SECRET. Empty allowlists ignore every notification.

cURL
curl --fail-with-body --tlsv1.2 \
  -H 'X-Telegram-Bot-Api-Secret-Token: TELEGRAM_WEBHOOK_SECRET' \
  -H 'Content-Type: application/json' \
  --data @update.json \
  https://payments.example.com/telegram.php

Merchant callbacks

Callback URLs must use operator-allowlisted HTTPS hostnames on port 443 with publicly routable IPv4 addresses. The checked DNS result is pinned for the connection; redirects, proxies, and private or reserved addresses are rejected.

Delivery headers
HeaderMeaning
X-Callback-Timestamp Unix seconds at delivery.
X-Callback-Signature sha256= plus HMAC-SHA256 of timestamp + "." + raw_request_body.

Delivery is at least once, including after a worker crash. Receivers must be idempotent and return 2xx only after durably recording the event.

Callback verification

Verify the raw request body before parsing JSON, compare with a constant-time function, and reject timestamps outside a short tolerance.

Python
import hmac, hashlib, time

def verify_callback(raw_body, timestamp, signature, secret):
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = hmac.new(secret, (timestamp + ".").encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest("sha256=" + expected, signature)

Deduplicate by event_id or order_id before applying business effects. A repeated delivery that passes signature checks is still a repeated delivery.

Status codes and duplicate behavior

  • success / not_foundBoth matched and unmatched payment events return HTTP 200 when accepted. Inspect data.status.
  • conflictReusing an event ID with a different amount, card suffix, or timestamp.
  • validationInvalid JSON, missing fields, bad timestamp window, or unsupported amount.
  • authMissing or invalid credential header.
  • large bodyJSON request body exceeds 64 KiB.
  • media typeUnsupported content type.
  • not foundUnknown endpoint, or order_not_found when the order does not exist or belongs to another merchant.
  • methodWrong HTTP method. The Allow header names the accepted one.
  • rate limitedToo many requests from your IP. Back off, then continue.
  • high_load / no_active_cardsTemporary overload or no card available. Retry after Retry-After seconds; reuse the same event_id for payment events.
  • server errorinternal_server_error carries no details. Retry with backoff and contact the operator if it persists.
  • A duplicate event returns the recorded outcome with duplicate: true.
  • A duplicate never searches for another invoice.
  • Errors always look like {"status":"error","message":"…"}; branch on the HTTP status and message, never on free text.
  • Never trust the top-level HTTP success alone as proof of settlement; inspect data.status.

Security requirements

  1. Transport

    HTTPS only. No credentials in query strings, logs, or browser bundles.

  2. Amounts

    Whole UZS integers only. Range and uniqueness constraints are enforced in the database.

  3. Idempotency

    Stable event IDs, conflicts on mismatched reuse, duplicates never re-match an invoice.

  4. Callbacks

    Exact host allowlisting, DNS pinning, no redirects, no proxies, TLS verification.

  5. Ingestion

    Telegram allowlists for senders and business connections. Forwards and edits are rejected.

  6. Secrets

    Loaded from the environment or .env. Never printed, logged, or returned.

Client examples

cURL
Shell
curl --fail-with-body --tlsv1.2 \
  -H 'Authorization: Bearer API_KEY' \
  -H 'Content-Type: application/json' \
  --data '{"amount":50000}' \
  https://payments.example.com/api/create/
JavaScript (Node.js)
Node.js 18+
const response = await fetch("https://payments.example.com/api/create/", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PAYMENT_API_KEY}`,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ amount: 50000 })
});
if (!response.ok) throw new Error(`Payment API HTTP ${response.status}`);
const result = await response.json();
if (result.status !== "success") throw new Error(result.message);
console.log(result.data.order_id, result.data.amount);
Python
Python 3
import os, requests

response = requests.post(
    "https://payments.example.com/api/create/",
    headers={"Authorization": f"Bearer {os.environ['PAYMENT_API_KEY']}"},
    json={"amount": 50000},
    timeout=(3, 10),
)
response.raise_for_status()
result = response.json()
if result.get("status") != "success":
    raise RuntimeError(result.get("message", "payment API error"))
print(result["data"]["order_id"])
PHP
PHP 8.2+
$curl = curl_init("https://payments.example.com/api/create/");
curl_setopt_array($curl, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => json_encode(["amount" => 50000], JSON_THROW_ON_ERROR),
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("PAYMENT_API_KEY"),
        "Content-Type: application/json",
    ],
    CURLOPT_RETURNTRANSFER => true,
]);
$body = curl_exec($curl);
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
if ($body === false || $status < 200 || $status >= 300) {
    throw new RuntimeException("Payment API request failed");
}