# KS Pay Crypto — Project API KS Pay Crypto lets your project accept **USDT deposits** and send **USDT withdrawals** on **BNB Smart Chain (BEP-20)**. Each of your users gets a permanent deposit address; when USDT arrives, your project balance is credited and you receive a webhook. Withdrawals are paid from the gateway after an admin approves them. This document is the complete integration reference. It is written so that both developers and AI coding agents can implement an integration from it alone. ## 1. Basics | | | |---|---| | Base URL | `https://crypto.kspyment.com/v1` | | Network | BNB Smart Chain mainnet (chain ID 56) — see [§10](#10-network) | | Asset | `USDT` only | | Format | JSON in and out, UTF-8. Send `Content-Type: application/json` on POST | | Amounts | **Decimal strings**, never numbers: `"25"`, `"10.5"`, `"0.000001"`. Up to 18 decimal places. Parse them with a decimal library, not floats | | Addresses | `0x` + 40 hex characters. Responses always use lowercase | | Times | ISO 8601 UTC, e.g. `"2026-11-09T08:22:30.318Z"` | | IDs | Deposits start with `dep_`, withdrawals with `wd_`, webhook events with `evt_` | The API is meant to be called **from your server**. Never put the API key in a browser or mobile app. ## 2. Authentication Every request needs your project's API key as a bearer token: ``` Authorization: Bearer kspk_live_… ``` - Keys are created by a KS Pay admin in the portal (Projects → your project → API keys) and shown **once**. - Keys start with `kspk_live_`. - A missing, wrong or revoked key returns `401` with code `invalid_api_key`. - Limit: **600 requests per minute per key**; above that you get `429` with code `rate_limited`. Back off and retry. ## 3. Errors Every error has the same shape and a stable machine-readable `code`: ```json { "error": { "code": "insufficient_balance", "message": "insufficient_balance" } } ``` Branch on `code`, not on `message`. Codes you can receive from this API: | HTTP | `code` | Meaning | |---|---|---| | 400 | `invalid_request` | Body or query is malformed (wrong type, missing field) | | 400 | `invalid_external_user_id` | `external_user_id` is not 1–128 characters of `A–Z a–z 0–9 _ . @ : -` | | 400 | `invalid_amount` | Amount is not a positive decimal string | | 400 | `invalid_address` | `to_address` is not a valid `0x…` address | | 400 | `invalid_idempotency_key` | `idempotency_key` is not 1–100 characters of `A–Z a–z 0–9 _ . : -` | | 400 | `below_min_withdrawal` / `above_max_withdrawal` | Outside your project's withdrawal limits | | 400 | `internal_address` | `to_address` belongs to the gateway (e.g. a deposit address); not allowed | | 400 | `invalid_id` | Transaction ID does not start with `dep_` or `wd_` | | 401 | `invalid_api_key` | See §2 | | 401 / 403 | `project_disabled` | Your project is disabled by an admin (401 on any call, 403 when requesting a withdrawal) | | 404 | `not_found` | No such address or transaction for your project | | 409 | `insufficient_balance` | Available balance is lower than the withdrawal amount | | 409 | `idempotency_mismatch` | Same `idempotency_key` reused with a different amount or address | | 409 | `idempotency_in_progress` | Same key is being processed by a concurrent request; retry shortly | | 429 | `rate_limited` | See §2 | | 503 | `chain_not_configured` | Gateway is being set up; retry later | | 500 | `internal` | Unexpected error; safe to retry idempotent requests | ## 4. Deposits ### 4.1 Get a deposit address ``` POST /v1/deposit-addresses ``` ```json { "external_user_id": "user-42" } ``` `external_user_id` is **your** identifier for the user (or wallet, account…). The same ID always gets the **same address**, forever — call this endpoint as often as you like; it is idempotent. Different IDs get different addresses. Response `201 Created` (new) or `200 OK` (existing): ```json { "external_user_id": "user-42", "address": "0xf70cf12de23539274d540782f572b0ccc6cd3b98", "network": "BSC", "asset": "USDT", "created_at": "2026-11-09T08:20:11.402Z" } ``` `network` is always `BSC`. Show the user the address (and a QR code of the plain address) and tell them to send **USDT on BNB Smart Chain (BEP-20) only**. USDT sent on another network (TRC-20, ERC-20…) or any other token is not credited. ### 4.2 Look up an existing address ``` GET /v1/deposit-addresses/{external_user_id} ``` Returns the same object, or `404 not_found` if none was created yet. ### 4.3 What happens when USDT arrives 1. The gateway sees the transfer and records a deposit with status `confirming` → webhook `deposit.detected`. 2. Once the block is final on BSC (usually seconds), the deposit becomes `credited`, your project's available balance increases by the amount → webhook `deposit.credited`. **Credit your user on this event only.** 3. Each on-chain transfer is one deposit, credited exactly once. Two transfers to the same address are two deposits. Other outcomes: | Status | Meaning | Webhook | |---|---|---| | `below_minimum` | Amount is under the minimum deposit (currently **2 USDT**). Not credited automatically; an admin may credit it manually, which then sends `deposit.credited` | none until credited | | `dropped` | The transaction disappeared from the chain before finality (rare). Never credited | `deposit.dropped` | ## 5. Balance ``` GET /v1/balances ``` ```json { "balances": [{ "asset": "USDT", "available": "1240.5", "reserved": "60" }] } ``` - `available` — credited deposits minus withdrawals; what you can withdraw now. - `reserved` — withdrawals requested but not finished yet. ## 6. Withdrawals ### 6.1 Request a withdrawal ``` POST /v1/withdrawals ``` ```json { "asset": "USDT", "amount": "25", "to_address": "0x1f2e3d4c5b6a79881726354453627180fedcba98", "idempotency_key": "payout-2026-11-09-000123" } ``` - `asset` is optional and defaults to `USDT`. - `idempotency_key` is **required**: a unique ID from your system for this payout. If your request times out, send the **same request with the same key** — you get the original withdrawal back (`200`) instead of a second payout. Reusing a key with a different amount or address returns `409 idempotency_mismatch`. - The amount is **reserved** from your available balance immediately. - Your project has a minimum and maximum per withdrawal (set by the admin); outside them you get `400`. Response `201 Created` (new) or `200 OK` (same key seen before): ```json { "id": "wd_9c41e07a2b", "type": "withdrawal", "asset": "USDT", "amount": "25", "status": "pending_approval", "to_address": "0x1f2e3d4c5b6a79881726354453627180fedcba98", "idempotency_key": "payout-2026-11-09-000123", "tx_hash": null, "requested_at": "2026-11-09T09:01:02.113Z", "updated_at": "2026-11-09T09:01:02.113Z" } ``` ### 6.2 Withdrawal lifecycle ``` pending_approval → approved → [awaiting_liquidity →] broadcasted → completed pending_approval → rejected broadcasted → failed ``` | Status | Meaning | Funds | |---|---|---| | `pending_approval` | Waiting for a KS Pay admin to approve | reserved | | `approved` | Approved, queued for sending | reserved | | `awaiting_liquidity` | Gateway hot wallet is temporarily short; sent automatically when topped up | reserved | | `broadcasted` | Sent to the network; `tx_hash` is set | reserved | | `completed` | Final on chain. **Terminal** | paid out | | `rejected` | Declined by an admin; `reason` in the webhook. **Terminal** | returned to available | | `failed` | The transaction reverted on chain. **Terminal** | returned to available | Every status change sends a webhook `withdrawal.` (e.g. `withdrawal.completed`). Approved withdrawals are paid in approval order. ## 7. Transactions ``` GET /v1/transactions?type=deposit&limit=50&cursor=… GET /v1/transactions?type=withdrawal GET /v1/transactions/{id} ``` - `type`: `deposit` (default) or `withdrawal`. `limit`: 1–100, default 50. Newest first. - Pagination: pass `next_cursor` from the previous page as `cursor`; `null` means no more pages. - `GET /v1/transactions/{id}` takes a `dep_…` or `wd_…` ID. List response: `{ "items": [ … ], "next_cursor": "50" }`. A withdrawal item has the shape shown in §6.1. A deposit: ```json { "id": "dep_44ce493be3", "type": "deposit", "external_user_id": "user-42", "asset": "USDT", "amount": "3", "status": "credited", "deposit_address": "0xf70cf12de23539274d540782f572b0ccc6cd3b98", "from_address": "0xf496e52c5a64c073ac834f9eb014fca8e6edc863", "tx_hash": "0xf8eaa42334733ab76c8b128e0e7dd86c677be7584409c9846ee19858566b9678", "log_index": 0, "block_number": 141692590, "detected_at": "2026-11-09T08:22:30.146Z", "credited_at": "2026-11-09T08:22:30.318Z" } ``` Use these endpoints to reconcile, or to recover if you missed webhooks. ## 8. Webhooks The gateway POSTs an event to your project's webhook URL on every deposit and withdrawal change. The URL (HTTPS) and its signing secret (`whsec_…`, shown once) are set by a KS Pay admin in the portal (Webhooks). ### 8.1 Request ``` POST Content-Type: application/json User-Agent: KSPay-Webhooks/1 X-KSPay-Event-Id: evt_5be0a91c3d X-KSPay-Timestamp: 1794209059 X-KSPay-Signature: 3f1c…(64 hex chars) ``` ```json { "event_id": "evt_5be0a91c3d", "event_type": "deposit.credited", "project_id": "prj_test_project", "transaction_id": "dep_44ce493be3", "external_user_id": "user-42", "asset": "USDT", "amount": "3", "status": "credited", "occurred_at": "2026-11-09T08:22:30.318Z", "tx_hash": "0xf8eaa42334733ab76c8b128e0e7dd86c677be7584409c9846ee19858566b9678", "log_index": 0, "block_number": 141692590, "deposit_address": "0xf70cf12de23539274d540782f572b0ccc6cd3b98" } ``` Withdrawal events carry `transaction_id` (`wd_…`), `asset`, `amount`, `to_address`, `status`, `occurred_at`, plus `tx_hash` (and `block_number` on `completed`) once sent, and `reason` on `rejected`. ### 8.2 Event types | Event | When | |---|---| | `deposit.detected` | USDT seen on a deposit address, not final yet (payload `status` is `confirming`) | | `deposit.credited` | Final and added to your balance — **credit the user now** | | `deposit.dropped` | Transaction vanished before finality; nothing credited | | `withdrawal.pending_approval` | Withdrawal created through the API | | `withdrawal.approved` / `withdrawal.awaiting_liquidity` / `withdrawal.broadcasted` | Progress | | `withdrawal.completed` | Paid on chain — final | | `withdrawal.rejected` / `withdrawal.failed` | Not paid; funds back in your available balance | | `test.ping` | Sent when an admin presses "Send test" in the portal | ### 8.3 Verify the signature `X-KSPay-Signature` = lowercase hex **HMAC-SHA256** of `"."` with your webhook secret as the key. Use the **raw** body bytes exactly as received (before JSON parsing). Reject the request if the signature does not match, or if the timestamp is more than 5 minutes from your clock. Node.js: ```js import { createHmac, timingSafeEqual } from 'node:crypto'; function verify(rawBody, headers, secret) { const ts = headers['x-kspay-timestamp']; const expected = createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex'); const given = String(headers['x-kspay-signature'] ?? ''); const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300; return fresh && given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected)); } ``` PHP: ```php $ts = $_SERVER['HTTP_X_KSPAY_TIMESTAMP'] ?? ''; $body = file_get_contents('php://input'); $expected = hash_hmac('sha256', $ts . '.' . $body, $secret); $ok = hash_equals($expected, $_SERVER['HTTP_X_KSPAY_SIGNATURE'] ?? '') && abs(time() - (int) $ts) < 300; ``` Python: ```python import hmac, hashlib, time expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest() ok = hmac.compare_digest(expected, signature) and abs(time.time() - int(ts)) < 300 ``` ### 8.4 Delivery rules - Reply with any **2xx** within 10 seconds to acknowledge. Anything else (or a timeout) is retried after 30 s, 1 min, 2 min, 5 min, 15 min, 30 min, 1 h, 2 h and 4 h; after that the event is marked failed (an admin can resend it). - Delivery is **at least once**: you may receive the same event more than once. Deduplicate on `event_id`, and make crediting idempotent on `transaction_id` (each deposit is credited once). - Events can arrive **out of order**. Use `status` and `occurred_at`, and never move a transaction backwards (e.g. ignore `deposit.detected` after you processed `deposit.credited`). - Do slow work after responding; the webhook only needs to be recorded. - If your endpoint was down, poll `GET /v1/transactions` to catch up. ## 9. Integration checklist 1. Store the API key and webhook secret in server-side secrets. 2. When a user wants to deposit, call `POST /v1/deposit-addresses` with your user ID and show the address and QR. Warn: USDT on BNB Smart Chain (BEP-20) only; minimum 2 USDT. 3. On `deposit.credited`, credit the user exactly once per `transaction_id`. 4. For payouts, call `POST /v1/withdrawals` with a unique `idempotency_key`; retry with the same key on timeouts. 5. Mark the payout paid on `withdrawal.completed`; refund your user on `withdrawal.rejected` / `withdrawal.failed`. 6. Verify every webhook signature; deduplicate on `event_id`. 7. Optionally reconcile daily with `GET /v1/balances` and `GET /v1/transactions`. ## 10. Network | | | |---|---| | Chain | BNB Smart Chain mainnet, chain ID `56` | | USDT contract | `0x55d398326f99059fF775485246999027B3197955` (BSC-USD, 18 decimals) | | Explorer | `https://bscscan.com` | | Minimum deposit | 2 USDT | | Default withdrawal limits | 1 – 5,000 USDT per withdrawal (the admin can change them per project) |