Přijímejte kryptoplatby
Paysell vypořádává TON a USDT v síti TON. Vytvoříte fakturu, my vám dáme odkaz, a jakmile jsou peníze potvrzeny on-chain a připsány na váš zůstatek, dostanete podepsaný callback.
Přehled#
Co Paysell dělá a co ne.
Paysell je platební procesor, ne peněženka. Nikdy nepracujete se soukromými klíči, nesledujete blockchain ani nerozhodujete, kdy je transakce konečná — to je na nás.
Každá faktura získá svou vlastní přijímací adresu. Když ji kupující zaplatí, počkáme, až síť potvrdí převod, odečteme náš poplatek a zbytek připíšeme na váš zůstatek. Vybíráte na jakoukoli adresu.
Jak probíhá platba#
Šest kroků, většinu z nich děláme my.
Šest kroků, většinu z nich děláme my:
- 1
Váš zákazník klikne na zaplatit
Váš server zavolá naše API s částkou a vaší vlastní referencí objednávky.
- 2
Vydáme adresu
Nová přijímací adresa se vezme z předgenerovaného poolu a přiřadí se k této faktuře. Jedna adresa patří přesně jedné otevřené faktuře, a tak se platba k ní přiřadí.
- 3
Zákazník pošle mince
Naskenuje QR kód nebo zkopíruje adresu. Pošlete ho na
payment_url, kterou vracíme, a stránka za vás vyřeší vše — částku, adresu, QR, odpočet, živý stav. - 4
Zaznamenáme převod
Dotazují se dva nezávislé zdroje dat blockchainu a jejich odpovědi se porovnají. Pokud se neshodují, zastavíme se, místo abychom vybrali pohodlnější odpověď.
- 5
Čekáme na finalitu
Zařazení do masterchainu plus tři bloky navrch. Zhruba patnáct sekund — platba, která vypadá vypořádaná a pak zmizí, by byla vaší ztrátou, takže toto riziko nepodstupujeme.
- 6
Připsáno, a jste informováni
Poplatek se odečte, zbytek přistane na vašem zůstatku a podepsaný webhook jde na váš server s vaším
order_id.
Od platby po callback: zhruba minuta — asi patnáct sekund síťových potvrzení, zbytek je naše procházení sledovaných adres.
Kam jdou peníze#
Poplatek a z čeho se počítá.
Poplatek je 0,2 %, pevně daný pro váš obchod v okamžiku registrace. Pokud se standardní sazba později změní, ta vaše se nezmění — je zapsána v každé faktuře jako číslo, ne jako odkaz na nastavení.
Poplatek se počítá z toho, co skutečně dorazí, ne z toho, co faktura požadovala. Vyfakturujete 5 USDT a přijde 20, poplatek se počítá z 20. Při nedoplatku se počítá z toho, co přišlo.
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 USDTPřeplatek je připsán v plné výši — rozdíl si neponecháváme. Nedoplatek nechává fakturu otevřenou, aby ji kupující mohl doplatit na stejnou adresu.
Rychlý start#
Pět minut k vaší první faktuře.
Pět kroků. Dva jsou kliknutí ve vašem účtu, jeden je jediný požadavek z vašeho serveru a poslední dva se stanou samy.
- 1
Vytvořte obchod
Ve svém účtu. Okamžitě začne přijímat platby — bez čekání na schválení. Ověření probíhá tiše na pozadí a omezuje pouze výběry, ne příchozí platby.
- 2
Vydejte API klíč
Váš obchod → API klíče → Nový klíč. Klíč i tajný klíč webhooku se zobrazí jen jednou a nikdy víc. Uchovávejte je jako heslo k databázi a nikdy je neposílejte do prohlížeče.
- 3
Vytvořte fakturu
Jeden požadavek z vašeho serveru, jeden odkaz zpět. Všechny čtyři ukázky níže posílají přesně totéž.
- 4
Pošlete kupujícího na
payment_urlTo je celá pokladna — částka, adresa, QR kód, odpočet, živý stav — a není co stavět. Co kupující skutečně vidí, ukazuje Pokladna.
- 5
Počkejte na webhook
Jakmile jsou peníze potvrzené on-chain a připsané, pošleme na váš server POST s podepsanou událostí
payment.credited. Ověřte podpis a pak označte objednávku jako zaplacenou — ale jen tehdy, když jedata.statusrovenpaidnebooverpaid. Viz Webhooky.
The same request, four ways
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",
"idempotency_key": "order-1042"
}'Přesměrujte kupujícího na payment_url z odpovědi. Hotovo — zbytek přijde jako webhook.
What to do next
- Write the webhook receiver — Webhooks and A complete receiver. Nothing else on this page matters as much: it is what turns a payment into a paid order.
- Handle
underpaidandoverpaid, not justpaid— see Status reference. - Read Typical mistakes, then walk the go-live checklist before you point real customers at it.
Autentizace#
Váš API klíč a jak se používá.
Každý požadavek nese váš klíč v hlavičce Authorization:
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxAKaždý klíč vydaný zde začíná na sk_live_. Prefix sk_test_ existuje jen u nasazení mířícího na testovací síť a takové nasazení nenabízíme — viz Testování. Ukládáme jednosměrný hash, ne samotný klíč, takže nikdo, ani my, vám ho nemůže znovu ukázat. Ztratili jste ho? Vydejte nový a ten starý odvolejte.
Obchod se odvozuje od klíče, proto žádný požadavek nikdy nenese id obchodu. Klíč může jednat pouze na svém vlastním obchodě.
V cestě je verze: /api/merchant/v1/…. Uvnitř verze pouze přidáváme pole — nic se nepřejmenovává a nic potichu nemění význam. Změna, která by rozbila váš kód, dostane novou předponu /v2 a /v1 běží dál po oznámenou dobu.
Endpoints at a glance#
Four calls, three of them authenticated.
This is the whole merchant API. Balances, payouts and history are not in it — they live in your account area, where a person is looking at them.
| Endpoint | Method | Auth | What it does |
|---|---|---|---|
| /invoices | POST | API key | Open an invoice and get a payment link. Details. |
| /invoices/{invoice_id} | GET | API key | Read one invoice's current state. Details. |
| /invoices/{invoice_id}/cancel | POST | API key | Close an invoice that is still open and free its address. Details. |
| /public/invoices/{invoice_id} | GET | none | What the hosted checkout page reads. Only needed if you build your own. Details. |
Every path is relative to https://paysell.me/api/merchant/v1. There is no list endpoint and no refund endpoint — see Refunds.
Vytvoření faktury#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoicesTělo požadavku
| Pole | Typ | Povinné | Popis |
|---|---|---|---|
| asset | string | ano | Buď TON, nebo USDT_TON. |
| amount | string | ano | Normální jednotky mince, jako řetězec: "5" je 5 USDT. Ne více desetinných míst, než má mince. Viz Částky. |
| order_id | string | ne | Vaše vlastní reference, až 200 znaků. Vrací se v každém webhooku — takto přiřadíte platbu k objednávce. |
| description | string | ne | Až 1000 znaků. Zobrazuje se kupujícímu na platební stránce. |
| ttl_minutes | number | ne | Jak dlouho zůstává faktura splatná, v minutách. 1–1440; vynechte ji a použije se výchozí hodnota — dnes 2 hodiny. |
| idempotency_key | string | ne | Až 200 znaků. Při opakování pošlete stejnou hodnotu a dostanete zpět stejnou fakturu místo druhé. Je to pole v těle požadavku, ne hlavička Idempotency-Key — ta se zde nečte. |
Odpověď · 201
{
"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"
}Jak to použít ve vaší objednávce
| Pole | Co s ním dělat |
|---|---|
| invoice_id | Uložte ho k vaší objednávce. Je to to, co identifikuje platbu všude jinde. |
| payment_url | Přesměrujte kupujícího sem. Nic dalšího není třeba stavět. |
| address | Pouze pokud stavíte vlastní pokladnu. Zobrazte ji přesně tak, jak byla přijata — viz varování níže. |
| amount | Částka v normálních jednotkách, přesně jak jste ji poslali. Tuto zobrazujte. |
| amount_minor | Tatáž částka jako celé číslo v nejmenší jednotce. S touto počítejte. |
| expires_at | Zobrazte odpočet. Po jeho uplynutí se adresa přestane pro tuto fakturu sledovat. |
| status | Zde je vždy pending. Skutečné změny přicházejí webhookem. |
UQ… na mainnetu, 0Q… na testnetu). Její převod, přikrášlení nebo záměna za jiné kódování stejné adresy způsobí, že mince poslané na ještě nenasazenou peněženku se odrazí zpět odesílateli.Čtení faktury#
GET /api/merchant/v1/invoices/{invoice_id}
/api/merchant/v1/invoices/{invoice_id}Stejný tvar jako výše, přičemž status, paid a paid_minor odrážejí současnost: paid je, kolik dorazilo v normálních jednotkách, paid_minor totéž jako celé číslo v nejmenší jednotce. Užitečné jako záloha, když webhook nedorazil, nebo na děkovací stránce.
Dotazujte se maximálně jednou za pár sekund a webhooky považujte za hlavní kanál. Faktury patřící jinému obchodu odpovídají 404 — ne 403, takže id nelze prozkoumat na existenci.
Zrušení faktury#
POST /api/merchant/v1/invoices/{invoice_id}/cancel
/api/merchant/v1/invoices/{invoice_id}/cancelUzavře fakturu, která je stále otevřená — pending nebo underpaid — a uvolní její adresu. Použijte, když zákazník opustí pokladnu: adresy jsou omezený zdroj a jejich vrácení udržuje pool zdravý.
Faktura, která už otevřená není, odpoví 409. Zrušení faktury ve stavu underpaid nikomu mince nevrací: peníze, které už byly připsány, zůstávají na vašem zůstatku, a uzavírá se jen možnost doplatit.
Webhooky#
Co přichází a jak to ověřit.
URL webhooku nastavíte při vytvoření klíče. Posíláme na ni POST, když je platba připsána — a když je zamítnut vklad zadržený k dodatečné kontrole. Každé doručení je podepsané a opakujeme je zhruba den a půl, dokud neodpovíte 2xx. Zboží uvolněte při status: paid nebo overpaid, ne při pouhém příchodu volání.
Události
| Událost | Kdy | Co je v těle |
|---|---|---|
| payment.credited | Převod je potvrzený v síti, náš poplatek je stržen a zbytek je na vašem zůstatku. | Pole uvedená níže. |
| payment.rejected | Vklad zadržený k dodatečné kontrole (viz Přehled stavů) byl zamítnut. Peníze na váš zůstatek nedorazí. | invoice_id, order_id, asset, amount, tx_hash a reason. Zboží neuvolňujte; pokud už faktura byla paid z dřívějšího převodu, týká se tato událost toho vkladu navíc, ne oné platby. |
Co přichází
{
"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": "97a1f0…"
}
}{
"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": "97a1f0…",
"reason": "could not be matched to any order"
}
}Mapování polí
| Pole | Význam |
|---|---|
| event_id | Jedinečné pro každou událost; je také v hlavičce X-Paysell-Event-Id. Uložte ho a ignorujte opakování — viz níže. |
| data.order_id | Vaše reference. Podle ní vyhledejte objednávku. |
| data.amount | Kolik kupující poslal v tomto převodu, v nejmenší jednotce — na rozdíl od API, které přijímá normální jednotky. |
| data.fee | Kolik jsme si vzali, v nejmenší jednotce. |
| data.credited | Kolik přistálo na vašem zůstatku: amount − fee, v nejmenší jednotce. |
| data.paid_minor | Kolik celkem na tuto fakturu doposud přišlo, v nejmenší jednotce. Pole, na kterém záleží u underpaid: stav říká, že přišlo méně, tohle říká o kolik. |
| data.asset | Mince, která skutečně dorazila. Ne nutně ta, kterou faktura požadovala. |
| data.asset_mismatch | Je přítomno, a to jako true, jen když dorazivší mince není mincí faktury. Peníze se vám připíšou, ale faktura zůstává nezaplacená a status nikdy nebude paid. |
| data.invoice_asset | Chodí spolu s asset_mismatch: mince, kterou faktura skutečně požaduje. |
| data.status | Aktuální stav faktury: pending, underpaid, paid, overpaid nebo expired. Porovnejte s tím, co jste očekávali. |
| data.tx_hash | On-chain transakce, pro vaše záznamy a podporu. |
Hlavičky u každého doručení
X-Paysell-Event: payment.credited
X-Paysell-Event-Id: 99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp: 1789000000
X-Paysell-Signature: sha256=6f1c0e6a…| Hlavička | Význam |
|---|---|
| X-Paysell-Event | Typ události: payment.credited nebo payment.rejected. |
| X-Paysell-Event-Id | Jedinečné pro každou událost. Právě podle této hodnoty deduplikujte. |
| X-Paysell-Timestamp | Kdy jsme podepsali, v unixových sekundách. Je součástí podepisovaného řetězce. |
| X-Paysell-Signature | sha256= následované HMAC v hexadecimálním zápisu. Viz níže. |
Ověřování podpisu
Každý požadavek je podepsán tajným klíčem webhooku, který se zobrazí jen jednou, při vytvoření klíče. Podpisem je HMAC-SHA256(secret, "{timestamp}.{raw_body}") — časové razítko z X-Paysell-Timestamp, doslovná tečka a pak bajty těla. Ověřte ho dřív, než začnete jednat: bez toho vám kdokoli, kdo zjistí vaši URL, může podstrčit zaplacenou objednávku.
Python:
import hmac, hashlib, time
def is_ours(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
if abs(time.time() - int(timestamp)) > 300: # ±5 minutes
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# compare_digest, not ==: a plain comparison leaks the answer through timing
return hmac.compare_digest("sha256=" + expected, signature)Node.js:
const crypto = require("node:crypto")
function isOurs(body, signature, timestamp, secret) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false // ±5 min
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), b = Buffer.from(signature)
// timingSafeEqual throws when the lengths differ, so check that first
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Podepisujte surové bajty těla, přesně jak byly přijaty. Pokud JSON rozeberete a znovu serializujete, bajty se změní — pořadí klíčů, mezery — a podpis nebude souhlasit. Porovnávejte v konstantním čase (hmac.compare_digest, crypto.timingSafeEqual): obyčejné == se vrátí rychleji, když nesedí už první bajt, a tento rozdíl stačí k uhodnutí podpisu po jednotlivých bajtech.
Okno časového razítka
Odmítněte vše, jehož časové razítko je od vašich vlastních hodin vzdálené více než pět minut, a to v obou směrech. Časové razítko je uvnitř podepisovaného řetězce právě proto, aby ho nešlo upravit bez porušení podpisu; teprve okno z toho dělá ochranu. Bez něj zůstává jednou zachycený požadavek platný navždy a lze ho kdykoli přehrát znovu — samotný podpis nikdy nevyprší. Udržujte hodiny svého serveru na NTP, jinak začne tato kontrola odmítat i dobrá doručení.
Duplicity
Stejná událost může dorazit vícekrát. Není to chyba: opakujeme, dokud neodpovíte 2xx, a doručení, které uspělo, ale jehož odpověď se k nám nikdy nedostala, se pošle znovu. Zaznamenejte X-Paysell-Event-Id (chodí i jako event_id v těle) a zajistěte, aby druhý příchod nic neudělal.
Opakování
První pokus odchází, jakmile je platba připsána. Pokud selže — timeout, odmítnuté spojení, chyba TLS, přesměrování nebo jakýkoli stav mimo 2xx — opakujeme podle pevného rozvrhu:
1 min → 5 min → 15 min → 1 h → 6 h → 24 hCelkem sedm pokusů rozložených zhruba do 31 hodin. Ty rané jsou blízko u sebe, protože obvyklou příčinou je příjemce, který se právě restartoval a už je zpátky; ty pozdní jsou řídké, protože bušit do serveru, který je den mimo provoz, nikomu nepomůže.
Po posledním pokusu je doručení označeno jako dropped a sami přestáváme. Není ztraceno: řádek platby ve vašem účtu ukazuje stav, počet pokusů a třídu chyby, spolu s tlačítkem Odeslat znovu, které spustí čerstvý běh všech sedmi pokusů. Vaší druhou možností je GET /api/merchant/v1/invoices/{invoice_id} — faktura svůj stav zná vždy.
Jak musí URL webhooku vypadat
URL se kontroluje při uložení a znovu před každým jednotlivým doručením. URL, která kontrolou neprojde, dostane při ukládání odpověď 422 s code: "webhook_url_rejected", a pokud začne selhávat později, označí doručení jako failed — bez opakování. Pravidla:
- Pouze `https://`, a port 443. Webhook nese údaje o platbě; v prostém http je může přečíst kdokoli na cestě.
- Doménové jméno, ne IP adresa. Certifikát stejně potřebujete a pro holé IP se certifikáty nevydávají.
- Žádný `localhost` a žádné jméno
.local,.internal,.corp,.lanani.test— naše servery se do vaší sítě nedostanou a jméno, které se překládá uvnitř té naší, je přesně to, co volat nesmíme. - Žádné přihlašovací údaje v URL (
https://user:pass@…). Pokud nějaký token potřebujete, dejte ten svůj do cesty nebo do parametru dotazu. - Každá adresa, na kterou se jméno přeloží, musí být veřejná — A i AAAA. Privátní, loopback, link-local a CGNAT rozsahy jsou odmítnuty a kontrola se opakuje před každým doručením, takže ani pozdější nasměrování záznamu na
127.0.0.1nefunguje. - Přesměrování je selhání, ne mezikrok. Nenásledujeme je: adresu, kterou jste nám dali, jsme zkontrolovali, tu v hlavičce
Locationne.
Odpovídejte rychle
Stačí jakékoli 2xx, a to do deseti sekund — to je celý náš timeout, včetně navázání spojení. Odpovězte nejdřív, pomalou práci dělejte až potom; endpoint, který před odpovědí čeká na vlastní databázi, bude nakonec zaznamenán jako timeout a zopakován, a vy zpracujete stejnou událost dvakrát. Cokoli jiného — 4xx, 5xx, přesměrování, zaseknutí — se počítá jako neúspěšný pokus a vrací se do rozvrhu výše.
Doručování na rovinu
Zaručený je mechanismus doručení: sedm pokusů zhruba během 31 hodin, ruční odeslání znovu z vašeho účtu a endpoint faktury, který skutečný stav zná vždy. Postavte průběh tak, aby vás webhook, který nikdy nedorazí, nic nestál — přečtěte fakturu na děkovací stránce nebo jednou za hodinu srovnejte otevřené faktury. Webhooky jsou rychlá cesta, ne jediná cesta.
A complete receiver#
Signature, deduplication and a fast answer, end to end.
The snippets above verify one signature. This is the whole endpoint: raw body, signature check, deduplication by event_id, a fast 2xx, and the one condition that is allowed to mark an order paid.
Node.js with Express. express.raw is the part people get wrong: express.json() hands you a parsed object, and bytes you re-serialise from it are not the bytes we signed.
const express = require("express")
const crypto = require("node:crypto")
const app = express()
const SECRET = process.env.PAYSELL_WEBHOOK_SECRET
function isOurs(body, signature, timestamp) {
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) // raw Buffer, not a parsed object
.digest("hex")
const a = Buffer.from(expected), b = Buffer.from(signature ?? "")
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
app.post(
"/paysell/webhook",
express.raw({ type: "application/json" }), // NOT express.json()
async (req, res) => {
const signature = req.get("X-Paysell-Signature")
const timestamp = req.get("X-Paysell-Timestamp")
if (!isOurs(req.body, signature, timestamp)) return res.sendStatus(401)
const event = JSON.parse(req.body.toString("utf8"))
// Answer first: 10 seconds is the whole timeout, connection included.
res.sendStatus(200)
// Deduplicate. In real code this is a unique column, not a Set.
if (await alreadyHandled(event.event_id)) return
await remember(event.event_id)
if (event.type !== "payment.credited") return
const { order_id, status, credited, asset, tx_hash } = event.data
// The only condition that may release the goods.
if (status !== "paid" && status !== "overpaid") return
await markOrderPaid(order_id, { credited, asset, tx_hash })
}
)Python with Flask. request.get_data() is the raw body; request.form and request.json are not.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["PAYSELL_WEBHOOK_SECRET"]
def is_ours(body: bytes, signature: str, timestamp: str) -> bool:
try:
sent_at = int(timestamp)
except (TypeError, ValueError):
return False
if abs(time.time() - sent_at) > 300: # ±5 minutes
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest("sha256=" + expected, signature)
@app.post("/paysell/webhook")
def paysell_webhook():
body = request.get_data() # raw bytes, unparsed
if not is_ours(body, request.headers.get("X-Paysell-Signature", ""),
request.headers.get("X-Paysell-Timestamp", "")):
return "", 401
event = json.loads(body)
# Deduplicate. In real code this is a unique column, not a set.
if already_handled(event["event_id"]):
return "", 200 # a repeat is still a success
remember(event["event_id"])
if event["type"] == "payment.credited":
data = event["data"]
# The only condition that may release the goods.
if data["status"] in ("paid", "overpaid"):
mark_order_paid(data["order_id"], data)
return "", 200 # 2xx within 10 secondsWhat the code is doing, and why
- Verify before anything acts on the body. An unsigned request that reaches your business logic is a paid order for whoever found your URL.
- Answer 2xx first, work afterwards. Ten seconds is the whole timeout, connection included. A handler that waits for its own database gets recorded as a timeout and retried, and you process the same event twice.
- Deduplicate on `event_id` in storage that survives a restart. The in-memory set in the examples keeps them short; a real one is a unique column in your database.
- Mark the order paid only on `status: paid` or `overpaid`.
underpaidmeans part of the money arrived and the invoice is still open, and a deposit in the wrong coin never makes an invoice paid either. - Answer 2xx to a duplicate too. A repeat that gets a 4xx looks like a failure to us and comes back again on the schedule.
payment.rejected arrives at the same endpoint. It means a deposit held for an additional check was declined and the money will not be credited: release nothing, and if the invoice was already paid by an earlier transfer, this event is about the extra deposit, not about that payment.
Přehled stavů#
Všechny stavy faktur a plateb, vysvětlené.
Faktura
| Stav | Význam | Co dělat |
|---|---|---|
| pending | Čeká na platbu. | Nechte objednávku otevřenou. |
| paid | Zaplaceno v plné výši. | Vydejte zboží. |
| overpaid | Přišlo více, než bylo požadováno. Přebytek je vám připsán v plné výši. | Vydejte zboží; rozdíl vraťte, pokud chcete. |
| underpaid | Přišlo méně, než bylo požadováno. Faktura zůstává otevřená a ponechává si svou adresu: kupující ji může doplatit na stejné místo a paid_minor říká, kolik už dorazilo. Splatná zůstává po zbytek své životnosti plus 24 hodin odkladu po expires_at. | Počkejte na doplatek, nebo se se zákazníkem vyrovnejte jinak. Nevydávejte zboží — faktura není zaplacená. |
| expired | Okno se uzavřelo, včetně odkladné lhůty. Stále na ní mohou být peníze: cokoli přišlo, zůstalo na vašem zůstatku, a paid_minor říká kolik. | Nabídněte novou fakturu. Nepřijímejte platbu na starou adresu: jakmile faktura vyprší, adresa se vrací do poolu a velmi opožděný převod je případ pro podporu, ne automatické připsání. Než zákazníkovi řeknete, že nic nedorazilo, zkontrolujte paid_minor. |
| cancelled | Zrušeno vámi. Adresa se vrací zpět do poolu. | Nic. |
Platba
Viditelné ve vašem účtu; užitečné při podpoře zákazníka během platby.
| Stav | Význam |
|---|---|
| detected | Zaznamenáno on-chain, čeká na potvrzení. |
| confirmed | Síť to potvrdila. Dále následuje připsání. |
| credited | Na vašem zůstatku. Toto je okamžik, kdy se spustí webhook. |
| review | Zadrženo k dodatečné kontrole — například mince přicházející na adresu bez otevřené faktury. |
| rejected | Nepřipsáno. Důvod je zaznamenán. |
Když platba jde do `review`
Některé vklady se místo okamžitého připsání zadrží k dodatečné kontrole: neobvykle velká částka, mince přicházející na adresu bez otevřené faktury, nebo neshoda dvou blockchainových zdrojů, které dotazujeme, o tom, co se vlastně stalo. Nic se neztrácí — peníze čekají na rozhodnutí a webhook se spustí, jakmile padne, což může být za minuty i za hodiny. Chybějící zpětné volání u platby zobrazené jako review berte jako normální stav, ne jako selhání. Pokud na tom pro objednávku záleží, obraťte se na podporu a uveďte tx_hash.
Částky#
Ven normální jednotky, zpět nejmenší.
Částky posílejte v normálních jednotkách mince, jako řetězec — "1.5" je jedna a půl. Ne JSON číslo a ne nejmenší jednotka.
| Aktivum | Desetinná místa | Posíláte | amount_minor v odpovědi |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "1.5" | "1500000" |
Řetězec, a ne číslo, protože čísla v JSON jsou IEEE-754 double a velká částka v nanotonech se do něj přestane vejít přesně. Více desetinných míst, než má mince, je 422, nikdy tiché zaokrouhlení vašich peněz. U webhooků je to naopak: tam jsou amount, fee a credited celá čísla v nejmenší jednotce, protože tu stranu čte kód, ne člověk.
// Send amounts in the coin's normal units, as a string:
const amount = "1.5" // one and a half TON or USDT
// In responses, amount is that same human string; amount_minor is the
// integer in smallest units — use it for exact maths, as a string or BigInt:
BigInt(invoice.amount_minor) // e.g. 1500000nLimity#
Minima, maxima a limity rychlosti.
| Limit | Hodnota | Při porušení |
|---|---|---|
| Minimální faktura | 0.1 TON · 3 USDT | 422 |
| Maximální faktura | 7000 TON · 10000 USDT | 422 |
| Faktur za hodinu, na obchod | 60 | 429 |
| Otevřených faktur najednou | 20, roste s každou zaplacenou fakturou, až na 200 | 429 |
| Životnost faktury | 1 minuta – 24 hodin (výchozí 2 hodiny) | 422 |
| Požadavků na API na klíč | 120 za minutu | 429 + Retry-After |
Minimum není byrokracie. Náš poplatek je procentuální, ale výběr platby stojí pevnou částku: přesun USDT z přijímací adresy znamená nejprve ji naplnit gasem — z naší kapsy. Pod pár dolary poplatek nepokryje zpracování a přijetí takové platby by znamenalo připsat vám peníze, jejichž přesun se nevyplatí.
Maximum tu není kvůli velkým obchodům — je to past na chybu v jednotkách. Pošlete "5000000" tam, kde jste mysleli "5", a bez něj by vznikla faktura na pět milionů dolarů: kupující uvidí nesmyslnou částku a odejde. Skutečná objednávka na tenhle strop nikdy nenarazí, chybná vždy. Oba stropy jsou nastavení (invoice_max_ton, invoice_max_usdt) a pro váš obchod je lze zvýšit — stačí požádat.
Hodinový limit i limit otevřených faktur chrání pool adres. Každá otevřená faktura zabírá přijímací adresu a nekontrolovaná smyčka na jednom webu by jinak vyčerpala pool pro všechny ostatní. Nový obchod smí mít najednou otevřených 20 faktur; příděl roste o jednu za každou fakturu, kterou skutečně vybral, až na strop 200. underpaid se počítá jako otevřená — stále drží svou adresu a čeká na zbytek. Zrušení opuštěné faktury vrátí její adresu okamžitě. Opakování se stejným idempotency_key se do hodinového limitu nepočítá.
Limit požadavků je 120 za minutu na jeden API klíč — dvě volání za sekundu, tedy vysoko nad jakýmkoli skutečným tokem objednávek. 429 nese hlavičku Retry-After v sekundách: počkejte tu dobu, místo abyste opakovali v těsné smyčce, což okno jen posouvá dál.
Chyby#
Stavové kódy, které skutečně uvidíte.
Chyby se vracejí jako JSON, a to ve dvou tvarech. Cokoli, o čem rozhodneme my nebo zpracovatelské jádro, dá pod detail dvojici {code, message}. Tělo požadavku, které neprojde validací, tam místo toho dá seznam chyb jednotlivých polí. Než přečtete detail.code, zkontrolujte, který z obou tvarů jste dostali — a větvěte podle `code`, nikdy podle `message`: formulace se může kdykoli změnit, kód ne.
{
"detail": {
"code": "invalid_input",
"message": "invoice amount below the minimum: 0.010000 USDT_TON, minimum 3.000000 USDT_TON"
}
}{
"detail": [
{
"type": "string_type",
"loc": ["body", "amount"],
"msg": "Input should be a valid string",
"input": 5
}
]
}| Stav | Kdy | Co dělat |
|---|---|---|
| 401 | Klíč chybí, je špatný nebo odvolaný. | Zkontrolujte hlavičku. Pokud byl odvolán, vydejte nový klíč. |
| 404 | Taková faktura neexistuje, nebo patří jinému obchodu. | Zkontrolujte id. Oba případy odpovídají stejně záměrně, aby se id nedalo zkoušet. |
| 409 | Faktura je ve stavu, který to zakazuje. | Nejprve si přečtěte její aktuální stav. |
| 422 | Požadavek má špatný tvar nebo je částka mimo hranice faktury. | Zpráva uvádí jak odeslanou hodnotu, tak limit. |
| 429 | Příliš mnoho faktur tuto hodinu, příliš mnoho otevřených najednou, nebo příliš mnoho požadavků. | Počkejte, až uplyne Retry-After, a zkuste to znovu. |
| 502 | Nepodařilo se nám dosáhnout zpracovatelského jádra. | Zkuste to znovu se stejným klíčem idempotence. |
Kódy
Tvar, o kterém rozhodujeme my, je {"detail": {"code": …, "message": …}}. Toto jsou kódy, které vrací API pro obchodníky.
| Kód | Stav | Význam |
|---|---|---|
| invalid_api_key | 401 | Klíč chybí, má špatný tvar, je neznámý nebo odvolaný. Všechny čtyři případy odpovídají stejně, takže klíč nelze zkoušet. |
| not_found | 404 | Takový objekt neexistuje, nebo patří jinému obchodu. |
| invalid_input | 422 | Požadavek neprošel validací v jádru — špatná částka, příliš mnoho desetinných míst, částka mimo hranice faktury. |
| conflict | 409 | Akce odporuje aktuálnímu stavu, například zrušení faktury, která už není otevřená. |
| too_many_requests | 429 | Limit rychlosti: faktur za hodinu, otevřených faktur, nebo požadavků za minutu. Retry-After říká, jak dlouho čekat. |
| cbc_unreachable | 502 | Nepodařilo se nám dosáhnout zpracovatelského jádra. Zkuste to znovu se stejným idempotency_key. |
| webhook_url_rejected | 422 | Jen při ukládání klíče: URL webhooku neprošla kontrolami výše. detail.reason uvádí, které pravidlo — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials a tak dále. |
502 neznamená, že faktura nebyla vytvořena — požadavek mohl projít s odpovědí ztracenou na zpáteční cestě. Zkuste to znovu se stejným idempotency_key a dostanete buď existující fakturu, nebo novou, nikdy dvě.
Checkout: what the buyer sees#
The hosted payment page, and when to build your own.
payment_url points at https://paysell.me/pay/{invoice_id}. One page, no account, no login, mobile first, and nothing for you to build.
On the page
- Your shop's name, the amount and the coin, large, with the invoice's description underneath.
- A countdown to
expires_at— plus the 24-hour grace period when the invoice isunderpaid. - The invoice's short id with a copy button, so a buyer can quote it to your support.
- Wallet buttons: Tonkeeper and MyTonWallet open with the address and the amount already filled in. Other reveals a QR code and the address with a copy button.
- A warning that only this invoice's coin, on the TON network, may be sent — anything else is lost.
How the page reacts
| Invoice | What the buyer sees |
|---|---|
| pending | “Waiting for payment”, with the wallet choices and the countdown. The page re-reads the invoice every five seconds. |
| underpaid | “Received X of Y”, the exact remainder still owed, and the same address to send it to. The wallet link is prefilled with what is missing, not with the original total — otherwise the buyer would pay twice. |
| paid · overpaid | “Payment received”, and a button back to your shop if the shop has a URL. |
| expired | “Payment window closed”. If money did arrive, the amount is named with a note to contact you — silence here would send the buyer looking for their coins. |
| cancelled | “Payment cancelled”, with a link back to your shop. |
If you build your own
You gain your own branding and take on all of the above: the exact address string, the right coin, the countdown with its grace period, the underpayment case, and polling. GET /api/merchant/v1/public/invoices/{invoice_id} is the same unauthenticated read the hosted page uses — rate limited per IP, so poll it no more often than every few seconds. Print the address exactly as returned.
Typical integration mistakes#
The handful that account for most broken integrations.
None of these are exotic. Every one of them has cost somebody a day.
Sending the amount as a number
{"amount": 5}is a422. It has to be the string"5": JSON numbers are IEEE-754 doubles, and a large sum in nanotons stops being exactly representable in one.Sending the smallest unit
"5000000"for 5 USDT is a mistake in units, and the upper invoice limit exists to catch it. Smallest units are what comes back in webhooks, not what goes out in requests.Treating the webhook's arrival as payment
Read
data.status.underpaidis not paid, and a deposit in a coin the invoice did not ask for never makes it paid either. Release the goods onpaidoroverpaid, on nothing else.Not checking the timestamp
A signature on its own never expires. Without the ±5 minute window on
X-Paysell-Timestamp, a delivery captured once can be replayed at any time and will still verify.Verifying the signature over re-serialised JSON
Parse the body and serialise it again and the bytes change — key order, spacing — and the HMAC no longer matches. Sign the raw bytes exactly as received.
No deduplication
The same
event_idwill arrive twice sooner or later: we retry until you answer 2xx, and a response lost on the way back looks like a failure from here. The second arrival must do nothing.Using the
Idempotency-KeyheaderThis API reads
idempotency_keyfrom the request body; the header is not read at all. A retry without the field opens a second invoice for the same order.Assuming
detailis always an objectIt is
{code, message}for anything we or the core decide, and a list of field errors when the body itself fails validation. Check which one you got before readingdetail.code.
Vrácení peněz#
Jak vrátit peníze zákazníkovi.
Vrácení peněz se řeší přes podporu, ne voláním API. Vrácení je nový převod na adresu, kterou dodal člověk, a platební procesor, který na volání API automaticky posílá peníze zpět, je platební procesor, který se dá přimět poslat peníze na adresu útočníka. Proto je to záměrně ruční.
Chcete-li kupujícímu vrátit peníze, otevřete ze svého účtu tiket na podporu s invoice_id nebo tx_hash, částkou a adresou, na kterou se má poslat. Operátor platbu zkontroluje, přesune peníze z vašeho zůstatku a odpoví ve stejném tiketu. Počítejte s pracovním dnem, ne s minutou.
Dva důsledky, se kterými stojí za to počítat už při návrhu. Přeplatek se vám připisuje v plné výši — nic si z něj nenecháváme — takže vrácení rozdílu kupujícímu, který poslal příliš mnoho, je na vás a jde stejnou cestou. A nedoplacená faktura není případem k vrácení, dokud je otevřená: peníze jsou na vašem zůstatku, adresa se stále sleduje a kupující může jednoduše doplatit. Teprve po odkladné lhůtě, kdy faktura přejde do expired s penězi na ní, je co rozhodovat.
Testování#
Jak otestovat integraci před spuštěním.
Klíče jsou tu ostré: každý vydaný klíč je klíč sk_live_ proti produkčnímu jádru a TON mainnetu. Samostatné testovací prostředí neexistuje, což má svou výhodu: procházíte přesně tu cestu, kterou půjdou skutečné objednávky.
Testujte tedy tak, jak byste testovali cokoli, co se dotýká skutečných peněz: na malých částkách. Vytvořte fakturu na minimum (0.1 TON nebo 3 USDT), zaplaťte ji z vlastní peněženky a sledujte celou cestu — platební stránku, webhook, kontrolu podpisu, překlopení vaší objednávky na zaplacenou. Poplatek se uplatní a mince se skutečně pohnou.
Části, které si můžete vyzkoušet, aniž byste cokoli utratili: vytvoření a přečtení faktury, její zrušení, 422 u chybně zadané částky, 401 u špatného klíče a vaše vlastní ověřování podpisu — podepište vzorové tělo svým tajným klíčem a předhoďte ho vlastnímu handleru. Skutečnou platbu vyžaduje jen poslední krok: opravdový webhook payment.credited.
Plánujte integraci tak, aby nezávisela na sandboxu ani na simulované platbě: ostrá cesta se ověří rychleji — a věrněji.
For AI agents and LLMs#
Machine-readable copies of this page, and a prompt to start from.
Everything on this page also exists in a form a model can read directly. Point your assistant at one of these instead of pasting screenshots of documentation into a chat.
The three files
| File | What it is | Use it for |
|---|---|---|
| /llms-full.txt | The whole documentation as one markdown file: endpoints, fields, statuses, limits, errors, webhooks with working verification code, the fee, the checkout page, the checklist. | Pasting into a model's context, or letting an agent fetch it. Start here. |
| /llms.txt | A short index in the llms.txt format: what Paysell is, the five rules that decide whether an integration works, and links to everything else. | Letting an agent discover the rest on its own. |
| /openapi.json | OpenAPI 3.1, generated from the running application's own models, both webhook events included. | Generating a client, or loading into anything that speaks OpenAPI. |
A prompt to start from
Copy this, replace the stack, and hand it to your assistant. It names the four things that go wrong most often, so the answer does not have to be corrected afterwards.
Read https://paysell.me/llms-full.txt and implement Paysell payments in my <stack>:
create invoices (POST /api/merchant/v1/invoices, Bearer sk_live_ key, amount as a
decimal string in normal units), redirect the buyer to payment_url, verify webhook
signatures (HMAC-SHA256 over "{timestamp}.{raw_body}", header X-Paysell-Signature,
reject anything whose X-Paysell-Timestamp is more than 300 seconds off), deduplicate
by event_id, answer 2xx within 10 seconds, and mark orders paid only on a
payment.credited event whose data.status is "paid" or "overpaid".Feeding it to a specific tool
- Agents with web access — Claude Code, Cursor, Windsurf and the like: give them the
/llms-full.txtlink. One fetch, no setup. - A chat window — ChatGPT, Claude, Gemini: paste the contents of
/llms-full.txtinto the conversation or attach it as a file. It is written to fit in one message. - OpenAPI tooling — client generators, Postman, an agent's tool schema: point it at
https://paysell.me/openapi.json. Itsserversentry already carries the production base URL, so generated calls go to the right place.
Kontrolní seznam před spuštěním#
Deset věcí ke kontrole před spuštěním.
- Klíč je pouze na straně serveru, nikdy v JavaScriptu prohlížeče.
- Podpis webhooku je ověřován proti
"{timestamp}.{raw_body}", a to v konstantním čase. - Doručení starší než pět minut jsou odmítána a hodiny serveru běží na NTP.
- Opakovaný
X-Paysell-Event-Idpodruhé nic neudělá. - Webhook odpoví 2xx do deseti sekund; pomalá práce probíhá až poté.
- URL webhooku je doména na https:// a portu 443, bez přesměrování před ní.
- Zmeškaný webhook se dá přežít: endpoint faktury se čte na děkovací stránce nebo při srovnávacím průchodu.
idempotency_keyse generuje jednou na objednávku a znovu použije při opakováních.- Částky odcházejí jako řetězce v normálních jednotkách; čísla z webhooku se čtou jako nejmenší jednotky.
- Adresa se zobrazuje přesně tak, jak byla vrácena, beze změny.
overpaidaunderpaidjsou ošetřeny, ne jenpaid;expiredmůže stále néstpaid_minor.- Zboží se uvolňuje při
status: paidnebooverpaid, nikdy při pouhém příchodu volání. 429se řeší vyčkánímRetry-After, ne okamžitým opakováním.- Zůstatky se čtou od nás, nesledují se odděleně jako pravda.
Něco není jasné?
Pokud tato stránka nezodpověděla vaši otázku, jde o mezeru v dokumentaci, kterou stojí za to nám nahlásit. Napište nám ze svého účtu a my opravíme stránku, ne jen odpověď.