Paysell

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.

Zůstatky žijí u nás a jsou jediným zdrojem pravdy. Zobrazujte je, ale nikdy si nedržte druhou kopii jako autoritativní — dva počítadla se nakonec vždy rozejdou a pak nikdo neví, které je správné.

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. 1

    Váš zákazník klikne na zaplatit

    Váš server zavolá naše API s částkou a vaší vlastní referencí objednávky.

  2. 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. 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. 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. 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. 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.

example
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

Př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.

Dostat mince z přijímací adresy stojí síťový gas a platíme ho my — této části se váš zůstatek nikdy nedotkne. Výběr na vlastní adresu je něco jiného: má svůj vlastní poplatek, strhává se z požadované částky a přesná čísla najdete v ceníku.

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. 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. 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. 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. 4

    Pošlete kupujícího na payment_url

    To 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. 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ž je data.status roven paid nebo overpaid. 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

Autentizace#

Váš API klíč a jak se používá.

Každý požadavek nese váš klíč v hlavičce Authorization:

http
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA

Kaž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.

Tento klíč vytváří faktury vaším jménem. Uchovávejte ho na straně serveru. Cokoli v JavaScriptu prohlížeče je veřejné, ať je to skryté sebelépe.

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.

EndpointMethodAuthWhat it does
/invoicesPOSTAPI keyOpen an invoice and get a payment link. Details.
/invoices/{invoice_id}GETAPI keyRead one invoice's current state. Details.
/invoices/{invoice_id}/cancelPOSTAPI keyClose an invoice that is still open and free its address. Details.
/public/invoices/{invoice_id}GETnoneWhat 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

POST/api/merchant/v1/invoices

Tělo požadavku

PoleTypPovinnéPopis
assetstringanoBuď TON, nebo USDT_TON.
amountstringanoNormální jednotky mince, jako řetězec: "5" je 5 USDT. Ne více desetinných míst, než má mince. Viz Částky.
order_idstringneVaše vlastní reference, až 200 znaků. Vrací se v každém webhooku — takto přiřadíte platbu k objednávce.
descriptionstringneAž 1000 znaků. Zobrazuje se kupujícímu na platební stránce.
ttl_minutesnumberneJak dlouho zůstává faktura splatná, v minutách. 1–1440; vynechte ji a použije se výchozí hodnota — dnes 2 hodiny.
idempotency_keystringneAž 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

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"
}

Jak to použít ve vaší objednávce

PoleCo s ním dělat
invoice_idUložte ho k vaší objednávce. Je to to, co identifikuje platbu všude jinde.
payment_urlPřesměrujte kupujícího sem. Nic dalšího není třeba stavět.
addressPouze 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_minorTatáž částka jako celé číslo v nejmenší jednotce. S touto počítejte.
expires_atZobrazte odpočet. Po jeho uplynutí se adresa přestane pro tuto fakturu sledovat.
statusZde je vždy pending. Skutečné změny přicházejí webhookem.
Pokud stavíte vlastní stránku, vytiskněte adresu přesně tak, jak byla vrácena. Je v non-bounceable formě (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}

GET/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

POST/api/merchant/v1/invoices/{invoice_id}/cancel

Uzavř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álostKdyCo je v těle
payment.creditedPřevod je potvrzený v síti, náš poplatek je stržen a zbytek je na vašem zůstatku.Pole uvedená níže.
payment.rejectedVklad 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í

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": "97a1f0…"
  }
}
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": "97a1f0…",
    "reason": "could not be matched to any order"
  }
}

Mapování polí

PoleVýznam
event_idJedineč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_idVaše reference. Podle ní vyhledejte objednávku.
data.amountKolik kupující poslal v tomto převodu, v nejmenší jednotce — na rozdíl od API, které přijímá normální jednotky.
data.feeKolik jsme si vzali, v nejmenší jednotce.
data.creditedKolik přistálo na vašem zůstatku: amount − fee, v nejmenší jednotce.
data.paid_minorKolik 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.assetMince, která skutečně dorazila. Ne nutně ta, kterou faktura požadovala.
data.asset_mismatchJe 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_assetChodí spolu s asset_mismatch: mince, kterou faktura skutečně požaduje.
data.statusAktuální stav faktury: pending, underpaid, paid, overpaid nebo expired. Porovnejte s tím, co jste očekávali.
data.tx_hashOn-chain transakce, pro vaše záznamy a podporu.

Hlavičky u každého doručení

http
X-Paysell-Event:      payment.credited
X-Paysell-Event-Id:   99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp:  1789000000
X-Paysell-Signature:  sha256=6f1c0e6a…
HlavičkaVýznam
X-Paysell-EventTyp události: payment.credited nebo payment.rejected.
X-Paysell-Event-IdJedinečné pro každou událost. Právě podle této hodnoty deduplikujte.
X-Paysell-TimestampKdy jsme podepsali, v unixových sekundách. Je součástí podepisovaného řetězce.
X-Paysell-Signaturesha256= 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:

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:

javascript
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 h

Celkem 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, .lan ani .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.1 nefunguje.
  • Přesměrování je selhání, ne mezikrok. Nenásledujeme je: adresu, kterou jste nám dali, jsme zkontrolovali, tu v hlavičce Location ne.
Ověření probíhá dvakrát záměrně — jednou při uložení URL, aby na překlep přišla odpověď hned, a ne tichým nedoručováním, a jednou před každým odesláním, protože majitel domény ji může kdykoli přesměrovat na interní adresu. Pokud se váš endpoint stěhuje, nejdřív aktualizujte klíč: odmítnutá URL nedoručí nic a nic se ani nezařadí do fronty.

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.

javascript
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.

python
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 seconds

What 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`. underpaid means 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

StavVýznamCo dělat
pendingČeká na platbu.Nechte objednávku otevřenou.
paidZaplaceno v plné výši.Vydejte zboží.
overpaidPř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.
underpaidPř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á.
expiredOkno 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.
cancelledZruš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.

StavVýznam
detectedZaznamenáno on-chain, čeká na potvrzení.
confirmedSíť to potvrdila. Dále následuje připsání.
creditedNa vašem zůstatku. Toto je okamžik, kdy se spustí webhook.
reviewZadrženo k dodatečné kontrole — například mince přicházející na adresu bez otevřené faktury.
rejectedNepř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.

AktivumDesetinná místaPosíláteamount_minor v odpovědi
TON9"1.5""1500000000"
USDT_TON6"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.

javascript
// 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. 1500000n

Limity#

Minima, maxima a limity rychlosti.

LimitHodnotaPři porušení
Minimální faktura0.1 TON · 3 USDT422
Maximální faktura7000 TON · 10000 USDT422
Faktur za hodinu, na obchod60429
Otevřených faktur najednou20, roste s každou zaplacenou fakturou, až na 200429
Životnost faktury1 minuta – 24 hodin (výchozí 2 hodiny)422
Požadavků na API na klíč120 za minutu429 + 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.

json
{
  "detail": {
    "code": "invalid_input",
    "message": "invoice amount below the minimum: 0.010000 USDT_TON, minimum 3.000000 USDT_TON"
  }
}
json
{
  "detail": [
    {
      "type": "string_type",
      "loc": ["body", "amount"],
      "msg": "Input should be a valid string",
      "input": 5
    }
  ]
}
StavKdyCo dělat
401Klíč chybí, je špatný nebo odvolaný.Zkontrolujte hlavičku. Pokud byl odvolán, vydejte nový klíč.
404Taková faktura neexistuje, nebo patří jinému obchodu.Zkontrolujte id. Oba případy odpovídají stejně záměrně, aby se id nedalo zkoušet.
409Faktura je ve stavu, který to zakazuje.Nejprve si přečtěte její aktuální stav.
422Požadavek má špatný tvar nebo je částka mimo hranice faktury.Zpráva uvádí jak odeslanou hodnotu, tak limit.
429Pří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.
502Nepodař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ódStavVýznam
invalid_api_key401Klíč 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_found404Takový objekt neexistuje, nebo patří jinému obchodu.
invalid_input422Požadavek neprošel validací v jádru — špatná částka, příliš mnoho desetinných míst, částka mimo hranice faktury.
conflict409Akce odporuje aktuálnímu stavu, například zrušení faktury, která už není otevřená.
too_many_requests429Limit rychlosti: faktur za hodinu, otevřených faktur, nebo požadavků za minutu. Retry-After říká, jak dlouho čekat.
cbc_unreachable502Nepodařilo se nám dosáhnout zpracovatelského jádra. Zkuste to znovu se stejným idempotency_key.
webhook_url_rejected422Jen 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 is underpaid.
  • 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

InvoiceWhat 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 a 422. 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. underpaid is not paid, and a deposit in a coin the invoice did not ask for never makes it paid either. Release the goods on paid or overpaid, 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_id will 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-Key header

    This API reads idempotency_key from the request body; the header is not read at all. A retry without the field opens a second invoice for the same order.

  • Assuming detail is always an object

    It 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 reading detail.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.

Berte svou první ostrou objednávku jako skutečný test: zvolte malou částku, nechte fakturu otevřenou v přehledu a než na ni pustíte skutečné zákazníky, zkontrolujte řádek platby a stav webhooku.

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

FileWhat it isUse it for
/llms-full.txtThe 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.txtA 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.jsonOpenAPI 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.

prompt
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.txt link. One fetch, no setup.
  • A chat window — ChatGPT, Claude, Gemini: paste the contents of /llms-full.txt into 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. Its servers entry 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-Id podruhé 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_key se 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.
  • overpaid a underpaid jsou ošetřeny, ne jen paid; expired může stále nést paid_minor.
  • Zboží se uvolňuje při status: paid nebo overpaid, nikdy při pouhém příchodu volání.
  • 429 se řeší vyčkáním Retry-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ěď.