# Paysell Merchant API — full documentation This is the complete merchant-facing documentation for Paysell in one file. It is written to be pasted into an LLM's context whole. Everything here is checked against the running code; a machine-readable version of the same contract is at https://paysell.me/openapi.json, and the human version at https://paysell.me/docs. --- ## 1. What Paysell is Paysell is a payment processor for crypto, not a wallet. It accepts **TON** and **USDT on the TON network** from buyers, deducts a fee, credits the rest to the merchant's balance, and pays out on request. The merchant never handles private keys, never watches the blockchain, and never decides when a transaction is final. The whole flow: 1. The buyer clicks "pay" in the merchant's shop. 2. The merchant's **server** calls `POST /invoices` with an amount and its own order reference. 3. Paysell allocates a receiving address and returns the invoice plus a `payment_url`. 4. The merchant redirects the buyer to `payment_url` — a hosted payment page with the amount, the address, a QR code, a countdown and a live status. 5. The buyer sends coins. Paysell polls two independent sources of blockchain data and only acts when they agree. 6. Once the transfer is included in the masterchain and three more blocks sit on top (roughly 15 seconds), the fee is deducted and the rest lands on the merchant's balance. 7. A signed `payment.credited` webhook is POSTed to the merchant's server, carrying the merchant's own `order_id`. Typical time from payment to webhook is about a minute: ~15 seconds of network confirmations, the rest is the sweep of watched addresses. **Not a wallet, not a bank.** Balances live with Paysell and are the single source of truth. Display them; never keep a second copy and treat it as authoritative. --- ## 2. Before you start You need three things, all from the account area at https://paysell.me: 1. **A shop.** It starts accepting payments immediately — there is no wait for review. Verification happens in the background and gates *withdrawals* only, never incoming payments. 2. **An API key.** Shop → API keys → New key. Shown once, stored as a one-way hash, so it can never be displayed again. Lost it → issue a new one and revoke the old. 3. **A webhook URL and its secret** (optional but strongly recommended). Set on the key. The webhook secret is also shown once, at key creation. --- ## 3. Base URL, versioning ``` https://paysell.me/api/merchant/v1 ``` The version is in the path on purpose. Inside a version fields are only ever **added** — nothing is renamed and nothing quietly changes meaning. A change that would break existing code gets a new prefix (`/v2`) and `/v1` keeps working for an announced period. An unversioned `/api/merchant/…` alias still answers for historical reasons. It is not documented, not in the schema, and will be removed. Use the versioned path. All request and response bodies are JSON. All timestamps are RFC 3339 in UTC. --- ## 4. Authentication Every merchant request carries the shop's API key as a bearer token: ``` Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA ``` - Keys issued on the production system start with `sk_live_`. `sk_test_` keys exist only on a testnet deployment, which is not offered to merchants — see §14. - **The shop is derived from the key.** No request body or path ever carries a shop id, and a key can only ever act on its own shop. This is deliberate: accepting a shop id from the caller would let the holder of one shop's key bill under another's. - Keep the key server-side. Anything in browser JavaScript is public, however well hidden. - A missing, malformed, unknown or revoked key all answer the same `401`: ```json {"detail": {"code": "invalid_api_key", "message": "Invalid or revoked API key."}} ``` The four cases are indistinguishable on purpose — a difference in the response would confirm which keys exist. Authentication is checked **before** the request body is validated. A bad key plus a malformed body answers `401`, not `422`. --- ## 5. Amounts and units This is where most integrations break. There are two conventions and they point in opposite directions. **In REST requests and responses**, `amount` is in the coin's **normal units, as a decimal string**, exactly like an exchange quotes it: | Asset | Decimals | One and a half | `amount_minor` in the response | |------------|----------|----------------|--------------------------------| | `TON` | 9 | `"1.5"` | `"1500000000"` | | `USDT_TON` | 6 | `"1.5"` | `"1500000"` | - A **string**, not a JSON number. JSON numbers are IEEE-754 doubles, and a large sum in nanotons stops being exactly representable in one. Sending `5` instead of `"5"` is a `422`. - No more decimal places than the coin has (TON 9, USDT 6). Extra places are a `422`, never a silent rounding of somebody's money. - Responses carry both: `amount` (human, the string you sent) and `amount_minor` (the same number as an integer in the smallest unit, as a string). **Compute with `amount_minor`**, compare it as a string or a big integer, never as a float. **Inside webhooks**, every money field — `amount`, `fee`, `credited`, `paid_minor` — is an **integer in the smallest unit**, delivered as a string. That side is read by code, not by a person. So `"5000000"` in a webhook for a `USDT_TON` payment means 5 USDT, while `"5000000"` in a create-invoice request would mean five million USDT and be rejected by the upper limit. --- ## 6. Endpoints Four endpoints, three of them authenticated. | Endpoint | Method | Auth | What it does | |-----------------------------------|--------|-----------|---------------------------------------| | `/invoices` | POST | API key | Open an invoice, get a payment link | | `/invoices/{invoice_id}` | GET | API key | Read one invoice's current state | | `/invoices/{invoice_id}/cancel` | POST | API key | Close an open invoice, free its address | | `/public/invoices/{invoice_id}` | GET | none | What the hosted checkout page reads | There is no list endpoint, no refund endpoint, no balance endpoint in the merchant API. Balances, payouts and history live in the account area. ### 6.1 POST /invoices — create an invoice Request body: | Field | Type | Required | Notes | |-------------------|---------|----------|---------------------------------------------------------------------------------------------| | `asset` | string | yes | `TON` or `USDT_TON`. Anything else is a `422`. | | `amount` | string | yes | Normal units, decimal string. Limits in §9. | | `order_id` | string | no | Your own reference, ≤ 200 chars. Echoed back in every webhook. This is how you match a payment to an order. | | `description` | string | no | ≤ 1000 chars. Shown to the buyer on the payment page. | | `ttl_minutes` | integer | no | 1–1440. How long the invoice stays payable. Omitted → the core default, currently 2 hours. | | `idempotency_key` | string | no | ≤ 200 chars. A **body field**, not a header. See §6.5. | There is no `shop_id` field. It comes from the key. ```bash curl -X POST https://paysell.me/api/merchant/v1/invoices \ -H "Authorization: Bearer sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "asset": "USDT_TON", "amount": "5", "order_id": "order-1042", "description": "Pro subscription", "idempotency_key": "order-1042" }' ``` Response, `201`: ```json { "invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e", "payment_url": "https://paysell.me/pay/12c22c1a-a496-4c1e-abe3-72661ef8706e", "address": "UQAvDJp7QDwqRcuNQBiK2GhBt71Xh1_UMYPCzMkQAoBPmZKl", "asset": "USDT_TON", "amount": "5", "amount_minor": "5000000", "status": "pending", "paid": "0", "paid_minor": "0", "order_id": "order-1042", "description": "Pro subscription", "expires_at": "2026-09-06T17:20:55Z", "created_at": "2026-09-06T15:20:55Z" } ``` Response fields: | Field | What to do with it | |----------------|----------------------------------------------------------------------------------------------------| | `invoice_id` | Store it against your order. It identifies the payment everywhere else. | | `payment_url` | Redirect the buyer here. Nothing else to build. | | `address` | Only needed if you render your own checkout. Print it **exactly** as returned — see the warning below. | | `asset` | The coin the invoice asks for. | | `amount` | Normal units, exactly as you sent. Show this one. | | `amount_minor` | The same as an integer in the smallest unit. Compute with this one. | | `status` | Always `pending` here. Real changes arrive by webhook. | | `paid` | How much has arrived so far, normal units. `"0"` on a fresh invoice. | | `paid_minor` | The same in the smallest unit. Matters on `underpaid`. | | `order_id` | Your reference, echoed back. | | `description` | As sent. | | `expires_at` | When it stops being payable. An `underpaid` invoice gets 24 hours of grace on top. | | `created_at` | When it was opened. | **The address must be printed exactly as returned.** It is in non-bounceable form (`UQ…` on mainnet, `0Q…` on testnet). Convert it, prettify it, or swap it for another encoding of the same address, and coins sent to a not-yet-deployed wallet bounce back to the sender. `payment_url` is null only on a deployment with no checkout page configured; on paysell.me it is always present. ### 6.2 GET /invoices/{invoice_id} — read an invoice Same shape as the create response, with `status`, `paid` and `paid_minor` reflecting the present. Use it on a thank-you page, as a fallback when a webhook was missed, or in a reconciliation sweep. An invoice belonging to another shop answers `404`, not `403` — the two cases answer alike so an id cannot be probed for existence. Treat webhooks as the primary channel and poll at most every few seconds. ### 6.3 POST /invoices/{invoice_id}/cancel — cancel an invoice Closes an invoice that is still open (`pending` or `underpaid`) and releases its receiving address back to the pool. Use it when a customer abandons checkout: addresses are a finite resource and returning them keeps the pool healthy. Returns the invoice in its new state. An invoice that is no longer open — `paid`, `overpaid`, `expired`, already `cancelled` — answers `409`. Cancelling an `underpaid` invoice does not return coins to anybody: money already credited stays on the merchant's balance. Cancelling closes the invoice, it does not undo a payment. ### 6.4 GET /public/invoices/{invoice_id} — the buyer's view No authentication: this is what the hosted payment page polls, and a buyer has no account. Safe to expose because the invoice id is a random UUID and the response carries nothing sensitive — the address is public on chain anyway, and the amount is what the buyer is about to pay. Narrower than the merchant view: no `order_id`, no `created_at`; plus `shop_name` and `shop_url` so the page is recognisable as this merchant's rather than a bare address. ```json { "invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e", "address": "UQAvDJp7QDwqRcuNQBiK2GhBt71Xh1_UMYPCzMkQAoBPmZKl", "asset": "USDT_TON", "amount": "5", "amount_minor": "5000000", "status": "pending", "paid": "0", "paid_minor": "0", "description": "Pro subscription", "expires_at": "2026-09-06T17:20:55Z", "shop_name": "Flower shop", "shop_url": "https://flowers.example" } ``` Rate limited per IP (60 requests per minute). Use it only if you render your own checkout; otherwise send the buyer to `payment_url` and let the hosted page do this. ### 6.5 Idempotency `idempotency_key` is a **field in the request body**, not the `Idempotency-Key` HTTP header. The header is not read by this API. Send the same value when retrying the same logical request and you get the same invoice back instead of a second one. - Generate it once per order, before the first attempt, and reuse it on every retry. - Without it, every call — including a retry after a lost response — opens a new invoice, and the buyer ends up with two links for one order. - Retries with the same key do not count against the hourly invoice cap. --- ## 7. Invoice statuses | Status | Meaning | What to do | |-------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------| | `pending` | Waiting for payment. | Keep the order open. | | `paid` | Paid in full. | Release the goods. | | `overpaid` | More arrived than asked. The surplus is credited in full — Paysell keeps none of it. | Release the goods; return the difference yourself if you wish. | | `underpaid` | Less arrived than asked. The invoice stays **open** and keeps its address: the buyer can top it up to the same place, and `paid_minor` says how much is in. It stays payable for the rest of its lifetime plus a 24-hour grace period after `expires_at`. | Wait for the top-up. **Do not release the goods.** | | `expired` | The window closed, grace period included. **May still carry money**: whatever arrived stayed on the merchant's balance, and `paid_minor` says how much. | Offer a new invoice. Check `paid_minor` before telling the buyer nothing was received. | | `cancelled` | Cancelled by the merchant. The address is released. | Nothing. | Payments (individual transfers) have their own statuses, visible in the account area: | Status | Meaning | |-------------|-------------------------------------------------------------------------------------| | `detected` | Seen on chain, waiting for confirmations. | | `confirmed` | The network confirmed it. Crediting next. | | `credited` | On the balance. This is when `payment.credited` fires. | | `review` | Held for an additional check — an unusual amount, coins at an address with no open invoice, or the two blockchain sources disagreeing. The money waits for a ruling. | | `rejected` | Not credited. The reason is recorded, and `payment.rejected` fires. | A payment sitting in `review` produces no webhook until the check clears, which can be minutes or hours. That is normal, not a failure. --- ## 8. What happens besides "paid exactly right" | Situation | What Paysell does | Invoice status | |----------------------------------------|------------------------------------------------------------|-------------------------------| | Paid exactly | credits it minus the fee | `paid` | | Paid less | credits what arrived, keeps waiting at the same address | `underpaid` | | Topped up to the full amount | credits the top-up, closes the invoice | `paid` (or `overpaid`) | | Paid more | credits everything that arrived | `overpaid` | | Paid in two transfers | credits each, closes on the total | `paid` | | Underpaid, grace period ran out | money stays with the merchant, top-ups are no longer accepted | `expired` | | Paid after expiry | recorded, handed to a human | `expired` | | Wrong coin sent to the invoice address | credited to the invoice's owner, invoice **not** closed | unchanged | **Wrong coin.** One deposit address serves both TON and USDT, so a USDT invoice can be paid in TON. The money is credited to you, but the invoice is not paid. The webhook for such a deposit carries `asset_mismatch: true` and `invoice_asset` (the coin the invoice actually asks for) alongside `asset` (the coin that arrived). Checking `status` alone is enough — on a mismatch it is never `paid` — but showing the two fields is what explains where unexpected TON on the balance came from. **Late payments.** An expired invoice's address goes back into the pool and, after a quarantine period, can be handed to another invoice. A payment that arrives very late is not credited to the original invoice, and recovering it is a support case, not an automatic credit. Tell buyers not to pay an expired address; issue a new invoice instead. --- ## 9. Limits | Limit | Value | On breach | |------------------------------|--------------------------------------------------|------------------------| | Minimum invoice | 0.1 TON · 3 USDT (inclusive) | `422` | | Maximum invoice | 7000 TON · 10000 USDT (inclusive) | `422` | | Invoices per hour, per shop | 60 | `429` | | Open invoices at once | 20 for a new shop, +1 per paid invoice, up to 200 | `429` | | Invoice lifetime | 1 minute – 24 hours (default 2 hours) | `422` | | API requests per key | 120 per minute | `429` + `Retry-After` | - The **minimum** exists because the fee is a percentage while handling a payment costs a fixed amount: moving USDT off a receiving address means funding that address with gas first, out of Paysell's own pocket. Below a few dollars the fee does not cover the handling. - The **maximum** is a trap for a units mistake, not a ceiling on real business. Send `"5000000"` where you meant `"5"` and you would otherwise get an invoice for five million dollars. A real order never reaches this ceiling; a mistake always does. Both ceilings are settings and can be raised for a shop on request. - The **hourly cap** and the **open-invoice cap** protect the address pool: each open invoice holds a receiving address. `underpaid` counts as open. Cancelling an abandoned invoice returns its address immediately. Retries with the same `idempotency_key` do not count. - A `429` carries `Retry-After` in seconds. Wait it out rather than retrying in a tight loop, which only pushes the window further out. --- ## 10. Errors The HTTP status says what kind of problem it is; the body carries the detail. **Branch on the code, never on the message** — wording can change at any time, codes cannot. Errors that Paysell or its processing core formulate come back as an object: ```json { "detail": { "code": "invalid_input", "message": "invoice amount below the minimum: 0.010000 USDT_TON, minimum 3.000000 USDT_TON" } } ``` Errors from request-body validation come back as a **list**, in FastAPI's standard shape: ```json { "detail": [ { "type": "string_type", "loc": ["body", "amount"], "msg": "Input should be a valid string", "input": 5 } ] } ``` A parser that assumes `detail` is always an object will crash on the first wrong field type. Check whether `detail` is a list before reading `detail.code`. | Status | When | What to do | |--------|---------------------------------------------------------------------|-------------------------------------------------------------------| | `401` | Key missing, malformed, unknown or revoked. | Check the header. Reissue the key if it was revoked. | | `404` | No such invoice, or it belongs to another shop. | Check the id. The two cases answer alike on purpose. | | `409` | The invoice is in a state that forbids this (e.g. cancelling a paid one). | Read its current status first. | | `422` | The body failed validation, or the amount is outside the invoice limits. | Fix the request. The message names both the value and the limit. | | `429` | Too many requests, too many invoices this hour, or too many open at once. | Wait out `Retry-After`, then retry. | | `502` | The processing core could not be reached. | Retry with the same `idempotency_key`. | Codes seen in `detail.code`: | Code | Status | Meaning | |------------------------|--------|-------------------------------------------------------------------------------| | `invalid_api_key` | 401 | The key is missing, malformed, unknown or revoked. | | `not_found` | 404 | No such object, or it belongs to another shop. | | `conflict` | 409 | The action contradicts the current state. | | `invalid_input` | 422 | Validation failed in the core — a bad amount, too many decimals, out of limits. | | `too_many_requests` | 429 | A rate limit. `Retry-After` says how long to wait. | | `cbc_unreachable` | 502 | The processing core did not answer. | | `cbc_error` | varies | The core refused for a reason without a more specific code. | | `webhook_url_rejected` | 422 | Only when saving an API key: the webhook URL failed the checks in §11.6. `detail.reason` names the rule. | A `502` does **not** mean the invoice was not created: the request may have gone through with the answer lost on the way back. Retry with the same `idempotency_key` and you either get the existing invoice or a new one, never two. --- ## 11. Webhooks ### 11.1 What arrives Two event types are forwarded to a merchant, and only when the shop's API key has a webhook URL configured: | Event | When | |--------------------|-------------------------------------------------------------------------------------------------| | `payment.credited` | A transfer is confirmed on chain, the fee is taken, and the rest is on the merchant's balance. | | `payment.rejected` | A deposit held for an additional check was declined. The money will **not** be credited. | Everything else — withdrawals, shop moderation — concerns the account owner, not the shop, and appears as an in-app notification instead. `payment.credited` body: ```json { "event_id": "99f74f58-efbb-4af1-b0a3-76b0073f9e6b", "type": "payment.credited", "data": { "invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e", "order_id": "order-1042", "asset": "USDT_TON", "amount": "5000000", "credited": "4905000", "fee": "95000", "status": "paid", "paid_minor": "5000000", "tx_hash": "97a1f0c3…" } } ``` | Field | Meaning | |----------------------|-----------------------------------------------------------------------------------------------------------| | `event_id` | Unique per event; also in `X-Paysell-Event-Id`. Deduplicate on it. | | `data.invoice_id` | The invoice this transfer landed on. | | `data.order_id` | Your own reference. Look your order up by this. Absent if you never sent one. | | `data.asset` | The coin that actually arrived — not necessarily the coin of the invoice. | | `data.amount` | What arrived in this transfer, smallest unit. | | `data.fee` | What Paysell took, smallest unit. | | `data.credited` | What landed on the balance: `amount − fee`, smallest unit. | | `data.status` | The **invoice's** status now: `pending`, `underpaid`, `paid`, `overpaid`, `expired` or `cancelled`. | | `data.paid_minor` | Total received on this invoice so far, in the invoice's coin, smallest unit. The field that matters on `underpaid`. | | `data.tx_hash` | The on-chain transaction, for records and support. | | `data.asset_mismatch`| Present, and `true`, only when the coin that arrived is not the coin of the invoice. | | `data.invoice_asset` | Comes with `asset_mismatch`: the coin the invoice actually asks for. | `payment.rejected` body — fewer fields, and not out of thrift: a rejected deposit has no `credited` and no `fee`, and the invoice's status does not change, because it was never paid. ```json { "event_id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d", "type": "payment.rejected", "data": { "invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e", "order_id": "order-1042", "asset": "USDT_TON", "amount": "5000000", "tx_hash": "97a1f0c3…", "reason": "could not be matched to any order" } } ``` Do not release the goods on this event. If the invoice was already `paid` by an earlier transfer, this event is about the extra deposit, not about that payment. ### 11.2 Headers | Header | Meaning | |-----------------------|-------------------------------------------------------------------------------------------| | `X-Paysell-Event` | `payment.credited` or `payment.rejected`. | | `X-Paysell-Event-Id` | Unique per event. This is the value to deduplicate on. | | `X-Paysell-Timestamp` | When it was signed, unix seconds. Part of the signed string. | | `X-Paysell-Signature` | `sha256=` followed by the hex HMAC. | | `X-Paysell-Delivery` | The event id again, under its old name. Kept so existing logs keep working; new code reads `X-Paysell-Event-Id`. | | `Content-Type` | `application/json`. | | `User-Agent` | `Paysell-Webhooks/1`. | ### 11.3 Verifying the signature The signature is `HMAC-SHA256(webhook_secret, "{timestamp}.{raw_body}")`, hex-encoded, sent as `sha256=`. The timestamp is the value of `X-Paysell-Timestamp`, then a literal dot, then the body bytes exactly as received. Verify before doing anything else. Without this, anyone who learns the URL can hand you a paid order. Python: ```python import hashlib import hmac import time def is_ours(body: bytes, signature: str, timestamp: str, secret: str) -> bool: # 1. The window. Without it a captured request stays valid forever. try: sent_at = int(timestamp) except (TypeError, ValueError): return False if abs(time.time() - sent_at) > 300: # +/- 5 minutes return False # 2. The signature, over the RAW bytes. signed = timestamp.encode() + b"." + body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() # 3. compare_digest, not ==: a plain comparison leaks the answer through timing. return hmac.compare_digest("sha256=" + expected, signature) ``` Node.js: ```js const crypto = require("node:crypto") function isOurs(body, signature, timestamp, secret) { const sentAt = Number(timestamp) if (!Number.isFinite(sentAt)) return false if (Math.abs(Date.now() / 1000 - sentAt) > 300) return false // +/- 5 minutes const expected = "sha256=" + crypto .createHmac("sha256", secret) .update(timestamp + ".") .update(body) // body is the raw Buffer, not a parsed object .digest("hex") const a = Buffer.from(expected) const b = Buffer.from(signature ?? "") // timingSafeEqual throws when the lengths differ, so check that first. return a.length === b.length && crypto.timingSafeEqual(a, b) } ``` **Sign the raw body bytes, exactly as received.** Parse the JSON and re-serialise it and the bytes change — key order, spacing — and the signature will not match. In Express that means `express.raw({ type: "application/json" })`, not `express.json()`. In Django it means `request.body`, not `request.POST`. ### 11.4 The timestamp window Reject anything whose `X-Paysell-Timestamp` is more than **300 seconds** from your own clock, in either direction. The timestamp is inside the signed string precisely so it cannot be edited without breaking the signature; the window is what turns that into replay protection. A signature alone never expires. Keep the receiving server's clock on NTP, or this check starts rejecting good deliveries. ### 11.5 Duplicates, retries and answering The same event **can** arrive more than once. That is by design: delivery is retried until the receiver answers 2xx, and a delivery that succeeded but whose response never came back gets sent again. Record `event_id` and make the second arrival do nothing. Answer with any 2xx within **10 seconds** — that is the entire timeout, connection included (5 seconds of it for the connection). Answer first, do the slow work afterwards. An endpoint that waits for its own database before replying will eventually be recorded as a timeout and retried, and the same event will be processed twice. Anything else — a 4xx, a 5xx, a redirect, a hang — counts as a failed attempt. Redirects are deliberately not followed: the address you gave was checked, the one in a `Location` header was not. Retry schedule after the first attempt, which goes out as soon as the payment is credited: ``` 1 min → 5 min → 15 min → 1 h → 6 h → 24 h ``` Seven attempts in total, spread over roughly 31 hours. After the last one the delivery is marked `dropped` and Paysell stops on its own. It is not lost: the payment row in the account area shows the state, the attempt count and the error class, with a **Send again** button that starts a fresh run of all seven attempts. There is **no delivery SLA**, and saying so is more useful than printing a number nobody measures. What is guaranteed is the mechanism. Build the flow so that a webhook that never arrives costs nothing: read the invoice on the thank-you page, or reconcile open invoices on a schedule. Webhooks are the fast path, not the only path. ### 11.6 What a webhook URL must look like The URL is checked when saved on a key, and again before every single delivery — a domain owner can repoint a record at an internal address at any moment. A URL that fails at save time answers `422` with `code: "webhook_url_rejected"` and a `reason`; one that starts failing later marks the delivery `failed` with **no retries** until the URL is fixed. - **`https://` only**, and **port 443**. - **A domain name, not an IP literal.** Certificates are not issued for bare IPs anyway. - **No `localhost`**, and no `.local`, `.localhost`, `.localdomain`, `.internal`, `.intranet`, `.lan`, `.corp`, `.private`, `.home.arpa`, `.test`, `.example`, `.invalid` or `.onion` name. - **A real dotted hostname.** A single-label host (`intranet`, `db`) is refused. - **No credentials in the URL** (`https://user:pass@…`). Put a token in the path or a query parameter instead. - **Every address the name resolves to must be public** — A and AAAA both. Private, loopback, link-local, multicast, reserved and CGNAT ranges are refused. - **Redirects are a failure, not a hop.** - At most 500 characters. `detail.reason` on a rejection is one of: `empty`, `too_long`, `malformed`, `scheme`, `credentials`, `host_missing`, `ip_literal`, `local_hostname`, `bad_hostname`, `port`, `dns_error`, `private_address`. ### 11.7 A complete receiver Node.js (Express): ```js const express = require("express") const crypto = require("node:crypto") const app = express() const SECRET = process.env.PAYSELL_WEBHOOK_SECRET const seen = new Set() // use your database in production app.post( "/paysell/webhook", express.raw({ type: "application/json" }), // raw bytes, not parsed JSON (req, res) => { const signature = req.get("X-Paysell-Signature") const timestamp = req.get("X-Paysell-Timestamp") if (!isOurs(req.body, signature, timestamp, SECRET)) { return res.status(401).end() } const event = JSON.parse(req.body.toString("utf8")) // Answer first. Everything below is fast and safe to redo. res.status(200).end() if (seen.has(event.event_id)) return // a repeat, on purpose seen.add(event.event_id) if (event.type !== "payment.credited") return const { order_id, status } = event.data if (status !== "paid" && status !== "overpaid") return // underpaid is not paid markOrderPaid(order_id, event.data) } ) ``` Python (Flask): ```python import json import os from flask import Flask, request app = Flask(__name__) SECRET = os.environ["PAYSELL_WEBHOOK_SECRET"] seen = set() # use your database in production @app.post("/paysell/webhook") def paysell_webhook(): body = request.get_data() # raw bytes, before any parsing if not is_ours(body, request.headers.get("X-Paysell-Signature", ""), request.headers.get("X-Paysell-Timestamp", ""), SECRET): return "", 401 event = json.loads(body) if event["event_id"] in seen: return "", 200 # a repeat: acknowledge, do nothing seen.add(event["event_id"]) if event["type"] == "payment.credited": data = event["data"] if data["status"] in ("paid", "overpaid"): mark_order_paid(data["order_id"], data) return "", 200 # 2xx within 10 seconds ``` --- ## 12. The fee **0.2%**, fixed for a shop at the moment it is registered. If the standard rate changes later, an existing shop keeps its own — the rate is written into each invoice as a number, not as a reference to a setting. The fee is taken from **what actually arrives**, not from what the invoice asked for: ``` Invoice: 5.000000 USDT Received: 20.000000 USDT (the buyer sent more) Fee 0.2%: 0.040000 USDT (on 20, not on 5) Credited: 19.960000 USDT ``` Overpayment is credited in full — Paysell keeps none of the difference. Underpayment leaves the invoice open so the buyer can top it up to the same address, and the fee applies to what came in. Network fees for moving the money afterwards are paid by Paysell, not deducted from a merchant's balance. Withdrawal fees are separate and listed in the Fee Schedule. --- ## 13. Checkout: what the buyer sees `payment_url` points at `https://paysell.me/pay/{invoice_id}`. It is a single page, no account, no login, mobile-first, and it works without any code on the merchant's side. The page shows: - the shop's name, the amount and the coin, large, plus the description from the invoice; - a countdown to `expires_at` (plus 24 hours when the invoice is `underpaid`); - the invoice's short id with a copy button, so a buyer can quote it to support; - wallet buttons — Tonkeeper and MyTonWallet open with the address and amount prefilled — and an "Other" option that reveals a QR code and the address with a copy button; - a warning that only the invoice's own coin, on the TON network, may be sent. The page polls the invoice every 5 seconds and reacts: | Invoice state | What the buyer sees | |-------------------------|--------------------------------------------------------------------------------------------------------| | `pending` | "Waiting for payment", with the wallet choices and the countdown. | | `underpaid` | "Received X of Y", the exact remainder still due, and the same address to send it to. | | `paid` / `overpaid` | "Payment received", and a button back to the shop if the shop has a URL. | | `expired` | "Payment window closed". If money did arrive, the amount is named, with a note to contact the shop. | | `cancelled` | "Payment cancelled", with a link back to the shop. | The QR code and the wallet links carry a `ton://transfer` payload whose `amount` is in the smallest unit, and for USDT the jetton master address as well — a link without it would make a wallet send plain TON. On an `underpaid` invoice the prefilled amount is what is **still missing**, not the original total. If you build your own checkout instead, you must reproduce all of that: the exact address string, the right coin, the countdown, the underpayment case, and polling. There is nothing to gain unless the branding matters. --- ## 14. Testing and refunds **Keys are live from the start.** Keys are prefixed `sk_live_` on the production system; `sk_test_` keys exist only on a testnet deployment, which is not handed out. Plan the integration against the live path — it is the same path real orders take. Test the way anything touching real money is tested: on small amounts. Create an invoice for the minimum (0.1 TON or 3 USDT), pay it from your own wallet, and watch the whole path — the payment page, the webhook, the signature check, your order flipping to paid. The fee applies and the coins really move. What can be exercised without spending anything: creating, reading and cancelling invoices; the `422` on a malformed amount; the `401` on a wrong key; and your own signature verification — sign a sample body with your secret and feed it to your own handler. Only the final `payment.credited` webhook needs a real payment. **Refunds go through support, not through an API call.** A refund is a new transfer to an address a person supplied, and a processor that sends money back on an API call is a processor that can be made to send money to an attacker's address. So it is deliberately manual: open a support ticket from the account area with the `invoice_id` or `tx_hash`, the amount and the destination address. Expect a working day, not a minute. Two consequences worth designing around. Overpayment is credited in full, so returning the difference is the merchant's call and follows the same route. And an underpaid invoice is not a refund case while it is still open — the money is on the balance, the address is still watched, and the buyer can simply top it up. --- ## 15. Common integration mistakes 1. **Sending `amount` as a JSON number.** `{"amount": 5}` is a `422`. It must be `"5"`. 2. **Sending the smallest unit.** `"5000000"` for 5 USDT is either an invoice for five million dollars or, more often, a `422` from the upper limit. Smallest units are what comes *back* in webhooks, not what goes out in requests. 3. **Releasing goods on the webhook arriving.** Check `data.status` — `underpaid` is not paid, and a deposit in the wrong coin never makes an invoice `paid`. 4. **Not verifying the signature, or verifying it over re-serialised JSON.** Sign the raw bytes and compare in constant time. 5. **Ignoring `X-Paysell-Timestamp`.** Without the ±300 second window, a captured delivery replays forever. 6. **No deduplication.** The same `event_id` will arrive twice sooner or later; the second time must be a no-op. 7. **Using the `Idempotency-Key` header.** This API reads `idempotency_key` from the body. A retry without it opens a second invoice. 8. **Doing slow work before answering.** The timeout is 10 seconds end to end. 9. **Re-encoding the address.** Print the `UQ…` string exactly as returned. 10. **Treating a `502` as "not created".** Retry with the same `idempotency_key`. 11. **Assuming `detail` is always an object.** Body-validation errors put a list there. 12. **Keeping the API key in browser code.** It creates invoices under your name. --- ## 16. Go-live checklist - API key is server-side only, never in browser JavaScript. - Webhook signature is verified against `"{timestamp}.{raw_body}"`, in constant time. - Deliveries older than 300 seconds are rejected, and the server's clock is on NTP. - A repeated `X-Paysell-Event-Id` does nothing the second time. - The webhook answers 2xx within 10 seconds; slow work happens afterwards. - The webhook URL is an `https://` domain on port 443, with no redirect in front of it. - A missed webhook is survivable: the invoice endpoint is read on the thank-you page or in a reconciliation sweep. - `idempotency_key` is generated once per order and reused on retries. - Amounts go out in normal units as strings; webhook figures are read as smallest units. - The address is displayed exactly as returned, unmodified. - `overpaid` and `underpaid` are handled, not just `paid`; `expired` may still carry `paid_minor`. - Goods are released on `status: paid` or `overpaid`, never on the callback merely arriving. - `429` is handled by waiting out `Retry-After`. - Balances are read from Paysell, not tracked separately as truth. --- ## 17. FAQ **How long does a payment take?** About a minute from the buyer's transfer to the webhook: roughly 15 seconds of network confirmations, the rest is the sweep of watched addresses. **Can I set my own invoice lifetime?** Yes, `ttl_minutes`, from 1 to 1440. The default is 2 hours. **What if the buyer pays twice?** Both transfers are credited. The invoice closes on the total and ends up `paid` or `overpaid`. **What if the buyer underpays?** The invoice stays open at the same address for the rest of its lifetime plus 24 hours. `paid_minor` says how much arrived. Do not ship until it reads `paid`. **Do I need to poll?** No, but it is a good fallback. Read the invoice on the thank-you page, and reconcile open invoices on a schedule so a lost webhook costs nothing. **Can I get the list of my invoices over the API?** Not in the merchant API. The account area has the history. **Can one key serve several shops?** No. A key belongs to one shop, and that is what makes a shop id unnecessary in requests. **What happens if I lose the webhook secret?** Issue a new key. Both the key and the secret are shown once and stored irreversibly. **Is there a test network?** No. See §14. **How do I refund a buyer?** Through support. See §14. **Where do I ask something this file does not answer?** From the account area — a question that has to be asked is a gap in this documentation, and the page gets fixed, not just the answer.