Przyjmuj płatności kryptowalutowe
Paysell rozlicza TON i USDT w sieci TON. Tworzysz fakturę, my przekazujemy Ci link, a Ty otrzymujesz podpisany callback, gdy środki zostaną potwierdzone w blockchainie i zaksięgowane na Twoim saldzie.
Przegląd#
Co Paysell robi, a czego nie.
Paysell to procesor płatności, a nie portfel. Nigdy nie masz do czynienia z kluczami prywatnymi, nie śledzisz blockchaina i nie decydujesz, kiedy transakcja jest ostateczna — to bierzemy na siebie.
Każda faktura ma własny adres odbioru. Gdy kupujący płaci, czekamy na potwierdzenie przelewu przez sieć, potrącamy naszą prowizję i resztę księgujemy na Twoim saldzie. Możesz wypłacać na dowolny adres.
Jak przebiega płatność#
Sześć kroków, większość z nich po naszej stronie.
Sześć kroków, większość z nich po naszej stronie:
- 1
Klient klika przycisk płatności
Twój serwer wywołuje nasze API z kwotą i własnym numerem referencyjnym zamówienia.
- 2
Wydajemy adres
Świeży adres odbioru jest pobierany z wcześniej wygenerowanej puli i przypisywany do tej faktury. Jeden adres należy do dokładnie jednej otwartej faktury — dzięki temu płatność jest z nią kojarzona.
- 3
Klient wysyła środki
Skanuje kod QR lub kopiuje adres. Skieruj go na zwrócony
payment_url, a strona sama się tym zajmie — kwota, adres, QR, odliczanie, status na żywo. - 4
Wykrywamy przelew
Odpytywane są dwa niezależne źródła danych blockchain, a ich odpowiedzi są porównywane. Jeśli się różnią, zatrzymujemy się, zamiast wybierać wygodniejszą odpowiedź.
- 5
Czekamy na finalność
Uwzględnienie w masterchainie plus trzy bloki na wierzchu. Około piętnastu sekund — płatność, która wygląda na rozliczoną, a potem znika, byłaby Twoją stratą, więc nie podejmujemy takiego ryzyka.
- 6
Zaksięgowane, i dostajesz informację
Prowizja jest potrącana, reszta trafia na Twoje saldo, a do Twojego serwera trafia podpisany webhook z Twoim
order_id.
Od płatności do callbacku mija około minuty: około piętnastu sekund na potwierdzenia sieci, reszta to nasze przejście po monitorowanych adresach.
Dokąd trafiają pieniądze#
Prowizja i od czego jest liczona.
Prowizja wynosi 0,2% i jest ustalana dla Twojego sklepu w momencie rejestracji. Jeśli standardowa stawka później się zmieni, Twoja pozostanie taka sama — jest zapisywana w każdej fakturze jako liczba, a nie jako odniesienie do ustawienia.
Prowizja jest pobierana od tego, co faktycznie wpłynęło, a nie od tego, o co poproszono w fakturze. Wystawisz fakturę na 5 USDT, a wpłynie 20 — prowizja jest liczona od 20. Przy niedopłacie liczona jest od tego, co wpłynęło.
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 USDTNadpłata jest księgowana w całości — nie zatrzymujemy różnicy. Niedopłata pozostawia fakturę otwartą, aby kupujący mógł dopłacić na ten sam adres.
Szybki start#
Pięć minut do pierwszej faktury.
Pięć kroków. Dwa to kliknięcia w panelu klienta, jeden to pojedyncze żądanie z Twojego serwera, a ostatnie dwa dzieją się same.
- 1
Utwórz sklep
W panelu klienta. Zaczyna przyjmować płatności natychmiast — bez czekania na weryfikację. Weryfikacja przebiega po cichu w tle i ogranicza jedynie wypłaty, nie płatności przychodzące.
- 2
Wygeneruj klucz API
Twój sklep → Klucze API → Nowy klucz. Klucz i sekret webhooka są pokazywane raz i nigdy więcej. Przechowuj je tak, jak hasło do bazy danych, i nigdy nie wysyłaj ich do przeglądarki.
- 3
Utwórz fakturę
Jedno żądanie z Twojego serwera, jeden link w odpowiedzi. Cztery przykłady poniżej wysyłają dokładnie to samo.
- 4
Skieruj kupującego na
payment_urlTo jest cała strona płatności — kwota, adres, kod QR, odliczanie, status na żywo — i nie ma nic do zbudowania. Zobacz Strona płatności, co dokładnie widzi kupujący.
- 5
Poczekaj na webhooka
Gdy pieniądze zostaną potwierdzone w blockchainie i zaksięgowane, wysyłamy POST z podpisanym zdarzeniem
payment.creditedna Twój serwer. Zweryfikuj podpis, a potem oznacz zamówienie jako opłacone — ale tylko wtedy, gdydata.statustopaidalbooverpaid. Zobacz Webhooki.
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"
}'Przekieruj kupującego na payment_url z odpowiedzi. Gotowe — reszta dotrze 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.
Uwierzytelnianie#
Twój klucz API i sposób jego użycia.
Każde żądanie przenosi Twój klucz w nagłówku Authorization:
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxAKażdy klucz wydawany tutaj zaczyna się od sk_live_. Prefiks sk_test_ istnieje wyłącznie we wdrożeniu wskazującym na sieć testową, a takiego wdrożenia nie oferujemy — zobacz Testowanie. Przechowujemy jednokierunkowy hash, a nie sam klucz, więc nikt, łącznie z nami, nie może pokazać go Tobie ponownie. Zgubiłeś go? Wygeneruj nowy i unieważnij stary.
Sklep jest wyznaczany na podstawie klucza, dlatego żadne żądanie nigdy nie przyjmuje id sklepu. Klucz może działać wyłącznie w obrębie własnego sklepu.
W adresie jest wersja: /api/merchant/v1/…. Wewnątrz wersji tylko dodajemy pola — nic nie jest zmieniane nazwą ani po cichu znaczeniem. Zmiana, która zepsułaby Twój kod, dostaje nowy przedrostek /v2, a /v1 działa przez zapowiedziany okres.
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.
Tworzenie faktury#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoicesTreść żądania
| Pole | Typ | Wymagane | Opis |
|---|---|---|---|
| asset | string | tak | TON albo USDT_TON. |
| amount | string | tak | Normalne jednostki monety, jako łańcuch znaków: "5" to 5 USDT. Nie więcej miejsc po przecinku, niż ma moneta. Zobacz Kwoty. |
| order_id | string | nie | Twój własny numer referencyjny, do 200 znaków. Wraca w każdym webhooku — dzięki temu dopasowujesz płatność do zamówienia. |
| description | string | nie | Do 1000 znaków. Wyświetlane kupującemu na stronie płatności. |
| ttl_minutes | number | nie | Jak długo faktura pozostaje możliwa do opłacenia, w minutach. 1–1440; pomiń to pole, a zadziała wartość domyślna — dziś 2 godziny. |
| idempotency_key | string | nie | Do 200 znaków. Wyślij tę samą wartość przy ponowieniu, a otrzymasz z powrotem tę samą fakturę zamiast drugiej. To pole w treści żądania, a nie nagłówek Idempotency-Key — tego nagłówka tutaj nie odczytujemy. |
Odpowiedź · 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"
}Odwzorowanie na Twoje zamówienie
| Pole | Co z nim zrobić |
|---|---|
| invoice_id | Zapisz je przy zamówieniu. To ono identyfikuje płatność wszędzie indziej. |
| payment_url | Przekieruj tu kupującego. Nie ma nic więcej do zbudowania. |
| address | Tylko jeśli renderujesz własną stronę płatności. Pokaż dokładnie tak, jak otrzymano — zobacz ostrzeżenie poniżej. |
| amount | Kwota w normalnych jednostkach, dokładnie tak, jak ją wysłałeś. Tę pokazuj. |
| amount_minor | Ta sama kwota jako liczba całkowita w najmniejszej jednostce. Na niej licz. |
| expires_at | Pokaż odliczanie. Po upływie tego czasu adres przestaje być monitorowany dla tej faktury. |
| status | Tutaj zawsze pending. Rzeczywiste zmiany przychodzą webhookiem. |
UQ… w sieci głównej, 0Q… w testowej). Przekonwertowanie go, upiększenie lub zamiana na inne kodowanie tego samego adresu spowoduje, że środki wysłane do jeszcze niewdrożonego portfela odbiją się z powrotem do nadawcy.Odczyt faktury#
GET /api/merchant/v1/invoices/{invoice_id}
/api/merchant/v1/invoices/{invoice_id}Ta sama struktura co powyżej, przy czym status, paid i paid_minor odzwierciedlają stan bieżący: paid to ile wpłynęło w jednostkach normalnych, a paid_minor to samo jako liczba całkowita w najmniejszej jednostce. Przydatne jako rozwiązanie zapasowe, gdy webhook nie dotarł, lub na stronie z podziękowaniem.
Odpytuj nie częściej niż co kilka sekund, a webhooki traktuj jako podstawowy kanał. Faktury należące do innego sklepu odpowiadają 404, a nie 403 — dzięki temu nie da się sprawdzić istnienia id.
Anulowanie faktury#
POST /api/merchant/v1/invoices/{invoice_id}/cancel
/api/merchant/v1/invoices/{invoice_id}/cancelZamyka fakturę, która jest wciąż otwarta — pending albo underpaid — i zwalnia jej adres. Użyj tego, gdy klient porzuca proces płatności: adresy to zasób ograniczony, a ich zwracanie utrzymuje pulę w dobrej kondycji.
Faktura, która nie jest już otwarta, odpowiada 409. Anulowanie faktury underpaid nie zwraca nikomu monet: pieniądze już zaksięgowane zostają na Twoim saldzie, a zamyka się jedynie możliwość dopłaty.
Webhooki#
Co przychodzi i jak to zweryfikować.
Adres webhooka ustawia się przy tworzeniu klucza. Wysyłamy tam POST, gdy płatność zostanie zaksięgowana — oraz gdy wpłata skierowana do dodatkowej weryfikacji zostanie odrzucona. Każda dostawa jest podpisana, a my ponawiamy próby przez około półtorej doby, dopóki nie odpowiesz 2xx. Towar wydawaj przy status: paid albo overpaid, a nie na samo nadejście wywołania.
Zdarzenia
| Zdarzenie | Kiedy | Co jest w treści |
|---|---|---|
| payment.credited | Przelew został potwierdzony w blockchainie, nasza prowizja została pobrana, a reszta trafiła na Twoje saldo. | Pola wymienione poniżej. |
| payment.rejected | Wpłata wstrzymana do dodatkowej weryfikacji (zob. Dokumentacja statusów) została odrzucona. Pieniądze nie trafią na Twoje saldo. | invoice_id, order_id, asset, amount, tx_hash i reason. Nie wydawaj towaru; jeśli faktura była już paid z wcześniejszego przelewu, to zdarzenie dotyczy nadmiarowej wpłaty, a nie tamtej płatności. |
Co przychodzi
{
"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"
}
}Mapowanie pól
| Pole | Znaczenie |
|---|---|
| event_id | Unikalny dla każdego zdarzenia; jest też w nagłówku X-Paysell-Event-Id. Zapisz go i ignoruj powtórzenia — zobacz poniżej. |
| data.order_id | Twój numer referencyjny. Szukaj po nim swojego zamówienia. |
| data.amount | Ile kupujący wysłał w tym przelewie, w najmniejszej jednostce — inaczej niż API, które przyjmuje jednostki normalne. |
| data.fee | Ile pobraliśmy, w najmniejszej jednostce. |
| data.credited | Ile trafiło na Twoje saldo: amount − fee, w najmniejszej jednostce. |
| data.paid_minor | Ile łącznie wpłynęło dotąd na tę fakturę, w najmniejszej jednostce. Pole, które liczy się przy underpaid: status mówi, że przyszło mniej, a to pole — o ile mniej. |
| data.asset | Moneta, która faktycznie przyszła. Niekoniecznie ta, o którą prosiła faktura. |
| data.asset_mismatch | Występuje, i to jako true, tylko wtedy, gdy przysłana moneta nie jest monetą faktury. Pieniądze są Ci zaksięgowane, ale faktura pozostaje nieopłacona, a status nigdy nie będzie paid. |
| data.invoice_asset | Przychodzi razem z asset_mismatch: moneta, której faktura naprawdę wymaga. |
| data.status | Bieżący status faktury: pending, underpaid, paid, overpaid albo expired. Porównaj z tym, czego oczekiwałeś. |
| data.tx_hash | Transakcja on-chain, do Twojej dokumentacji i wsparcia. |
Nagłówki przy każdej dostawie
X-Paysell-Event: payment.credited
X-Paysell-Event-Id: 99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp: 1789000000
X-Paysell-Signature: sha256=6f1c0e6a…| Nagłówek | Znaczenie |
|---|---|
| X-Paysell-Event | Typ zdarzenia: payment.credited albo payment.rejected. |
| X-Paysell-Event-Id | Unikalny dla każdego zdarzenia. To po tej wartości robi się deduplikację. |
| X-Paysell-Timestamp | Moment podpisania, w sekundach uniksowych. Jest częścią podpisywanego ciągu. |
| X-Paysell-Signature | sha256=, a po nim HMAC w zapisie szesnastkowym. Zobacz poniżej. |
Weryfikacja podpisu
Każde żądanie jest podpisywane sekretem webhooka, pokazanym jeden raz przy tworzeniu klucza. Podpis to HMAC-SHA256(secret, "{timestamp}.{raw_body}") — znacznik czasu z X-Paysell-Timestamp, dosłowna kropka, a potem bajty treści. Sprawdź go, zanim cokolwiek zrobisz: bez tego każdy, kto pozna Twój URL, może podsunąć Ci opłacone zamówienie.
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)
}Podpisuj surowe bajty treści, dokładnie takie, jakie otrzymano. Jeśli sparsujesz JSON i zserializujesz go ponownie, bajty się zmienią — kolejność kluczy, odstępy — a podpis przestanie się zgadzać. Porównuj w stałym czasie (hmac.compare_digest, crypto.timingSafeEqual): zwykłe == wraca szybciej przy błędnym pierwszym bajcie, a ta różnica wystarcza, by odgadnąć podpis bajt po bajcie.
Okno znacznika czasu
Odrzucaj wszystko, czego znacznik czasu odbiega od Twojego własnego zegara o więcej niż pięć minut, w którąkolwiek stronę. Znacznik czasu jest wewnątrz podpisywanego ciągu właśnie po to, by nie dało się go zmienić bez zepsucia podpisu; to okno zamienia tę własność w realną ochronę. Bez niego raz przechwycone żądanie pozostaje ważne na zawsze i można je odtworzyć w dowolnej chwili — sam podpis nigdy nie wygasa. Trzymaj zegar serwera na NTP, inaczej ta kontrola zacznie odrzucać poprawne dostawy.
Duplikaty
To samo zdarzenie może przyjść więcej niż raz. To nie błąd: ponawiamy próby, dopóki nie odpowiesz 2xx, a dostawa, która się powiodła, ale której odpowiedź do nas nie dotarła, zostaje wysłana ponownie. Zapisuj X-Paysell-Event-Id (przychodzi też jako event_id w treści) i spraw, by drugie nadejście niczego nie zmieniało.
Ponowienia
Pierwsza próba wychodzi, gdy tylko płatność zostanie zaksięgowana. Jeśli się nie uda — timeout, odrzucone połączenie, błąd TLS, przekierowanie albo dowolny status spoza 2xx — ponawiamy według stałego harmonogramu:
1 min → 5 min → 15 min → 1 godz. → 6 godz. → 24 godz.Łącznie siedem prób, rozłożonych na mniej więcej 31 godzin. Wczesne są blisko siebie, bo zwykłą przyczyną jest odbiorca, który właśnie się restartował i już wrócił; późne są rzadkie, bo dobijanie się do serwera leżącego od doby nikomu nie pomaga.
Po ostatniej próbie dostawa zostaje oznaczona jako dropped i przestajemy sami z siebie. Nie jest stracona: wiersz płatności w panelu klienta pokazuje stan, liczbę prób i klasę błędu, wraz z przyciskiem Wyślij ponownie, który uruchamia świeżą serię wszystkich siedmiu prób. Twoja druga droga to GET /api/merchant/v1/invoices/{invoice_id} — faktura zawsze zna swój własny status.
Jak musi wyglądać adres webhooka
URL jest sprawdzany przy zapisie i ponownie przed każdą pojedynczą dostawą. Adres, który nie przejdzie kontroli, dostaje przy zapisie 422 i code: "webhook_url_rejected", a jeśli zacznie zawodzić później, oznacza dostawę jako failed — bez ponowień. Zasady:
- Tylko `https://` i port 443. Webhook niesie szczegóły płatności; po zwykłym http są czytelne dla każdego po drodze.
- Nazwa domeny, nie adres IP. Certyfikat i tak jest potrzebny, a certyfikatów nie wystawia się dla samych adresów IP.
- Żadnego `localhost` ani nazw
.local,.internal,.corp,.lanczy.test— nasze serwery nie sięgną Twojej sieci, a nazwa, która rozwiązuje się wewnątrz naszej, to dokładnie to, czego wywołać nam nie wolno. - Żadnych danych logowania w adresie (
https://user:pass@…). Jeśli potrzebujesz tokena, umieść własny w ścieżce albo w parametrze zapytania. - Każdy adres, na który rozwiązuje się nazwa, musi być publiczny — zarówno A, jak i AAAA. Zakresy prywatne, pętli zwrotnej, link-local i CGNAT są odrzucane, a kontrola powtarza się przed każdą dostawą, więc późniejsze przestawienie rekordu na
127.0.0.1też nie zadziała. - Przekierowanie to porażka, nie kolejny krok. Nie podążamy za nimi: adres, który nam podałeś, został sprawdzony, a ten z nagłówka
Location— nie.
Odpowiadaj szybko
Wystarczy dowolne 2xx w ciągu dziesięciu sekund — to cały nasz timeout, wraz z nawiązaniem połączenia. Najpierw odpowiedz, wolną robotę zrób potem; endpoint, który przed odpowiedzią czeka na własną bazę, prędzej czy później zostanie zapisany jako timeout i ponowiony, a Ty przetworzysz to samo zdarzenie dwa razy. Wszystko inne — 4xx, 5xx, przekierowanie, zawieszenie — liczy się jako nieudana próba i wraca do harmonogramu powyżej.
Dostarczanie, uczciwie
Gwarantowany jest mechanizm dostarczania: siedem prób w ciągu mniej więcej 31 godzin, ręczne ponowne wysłanie z panelu klienta oraz endpoint faktury, który zawsze zna prawdziwy stan. Zbuduj przepływ tak, by webhook, który nigdy nie dotrze, nic Cię nie kosztował — odczytaj fakturę na stronie z podziękowaniem albo raz na godzinę uzgadniaj otwarte faktury. Webhooki to szybka droga, a nie jedyna.
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.
Dokumentacja statusów#
Każdy status faktury i płatności, wyjaśniony.
Faktura
| Status | Znaczenie | Co zrobić |
|---|---|---|
| pending | Oczekuje na płatność. | Trzymaj zamówienie otwarte. |
| paid | Opłacona w całości. | Wydaj towar. |
| overpaid | Wpłynęło więcej niż żądano. Nadwyżka jest księgowana na Twoją korzyść w całości. | Wydaj towar; jeśli chcesz, zwróć różnicę. |
| underpaid | Wpłynęło mniej niż żądano. Faktura pozostaje otwarta i zachowuje swój adres: kupujący może dopłacić w to samo miejsce, a paid_minor mówi, ile już wpłynęło. Można ją opłacać do końca jej życia plus 24 godziny karencji po expires_at. | Poczekaj na dopłatę albo dogadaj się z klientem. Nie wydawaj towaru — faktura nie jest opłacona. |
| expired | Okno się zamknęło, wliczając karencję. Może wciąż nieść pieniądze: to, co wpłynęło, zostało na Twoim saldzie, a paid_minor mówi ile. | Zaproponuj nową fakturę. Nie przyjmuj płatności na stary adres: gdy faktura wygaśnie, adres wraca do puli, a bardzo spóźniony przelew to sprawa dla wsparcia, a nie automatyczne zaksięgowanie. Sprawdź paid_minor, zanim powiesz klientowi, że nic nie wpłynęło. |
| cancelled | Anulowana przez Ciebie. Adres wraca do puli. | Nic. |
Płatność
Widoczna w panelu klienta; przydatna przy obsłudze klienta w trakcie płatności.
| Status | Znaczenie |
|---|---|
| detected | Zauważona w blockchainie, oczekuje na potwierdzenia. |
| confirmed | Sieć ją potwierdziła. Następne w kolejce jest zaksięgowanie. |
| credited | Na Twoim saldzie. W tym momencie uruchamiany jest webhook. |
| review | Wstrzymana do dodatkowej weryfikacji — na przykład środki, które trafiły na adres bez otwartej faktury. |
| rejected | Nie zaksięgowana. Powód jest zapisywany. |
Gdy płatność trafia do `review`
Część wpłat zamiast natychmiastowego zaksięgowania trafia do dodatkowej weryfikacji: nietypowo duża kwota, monety przychodzące na adres bez otwartej faktury albo rozbieżność między dwoma odpytywanymi przez nas źródłami danych blockchainowych. Nic nie ginie — pieniądze czekają na decyzję, a webhook przychodzi zaraz po niej, co może zająć minuty albo godziny. Brak wywołania przy płatności pokazanej jako review traktuj jako normalny, a nie jako awarię. Jeśli ma to znaczenie dla zamówienia, napisz do wsparcia i podaj tx_hash.
Kwoty#
Na zewnątrz jednostki normalne, z powrotem najmniejsze.
Kwoty wysyłaj w normalnych jednostkach monety, jako łańcuch znaków — "1.5" to półtora. Nie liczba JSON i nie najmniejsza jednostka.
| Aktywo | Miejsca dziesiętne | Wysyłasz | amount_minor w odpowiedzi |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "1.5" | "1500000" |
Łańcuch, a nie liczba, bo liczby JSON to double IEEE-754, a duża suma w nanotonach przestaje się w nich mieścić dokładnie. Więcej miejsc po przecinku, niż ma moneta, to 422, a nigdy ciche zaokrąglenie Twoich pieniędzy. W webhookach jest odwrotnie: tam amount, fee i credited to liczby całkowite w najmniejszej jednostce, bo tamtą stronę czyta kod, a nie człowiek.
// 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#
Minimum, maksimum i limity częstotliwości.
| Limit | Wartość | Przy naruszeniu |
|---|---|---|
| Minimalna faktura | 0.1 TON · 3 USDT | 422 |
| Maksymalna faktura | 7000 TON · 10000 USDT | 422 |
| Faktur na godzinę, na sklep | 60 | 429 |
| Otwartych faktur jednocześnie | 20, rośnie z każdą opłaconą fakturą, do 200 | 429 |
| Czas życia faktury | od 1 minuty do 24 godzin (domyślnie 2 godziny) | 422 |
| Żądań API na klucz | 120 na minutę | 429 + Retry-After |
Minimum to nie biurokracja. Nasza prowizja jest procentowa, ale przyjęcie płatności kosztuje stałą kwotę: przeniesienie USDT z adresu odbioru oznacza wcześniejsze zasilenie go gazem, z naszej kieszeni. Poniżej kilku dolarów prowizja nie pokrywa obsługi, a przyjęcie takiej płatności oznaczałoby zaksięgowanie Ci pieniędzy, których przeniesienie jest nieopłacalne.
Maksimum nie jest wymierzone w duże sklepy — to pułapka na błąd w jednostkach. Wyślesz "5000000" tam, gdzie miałeś na myśli "5", i inaczej dostałbyś fakturę na pięć milionów dolarów: kupujący widzi absurdalną kwotę i odchodzi. Prawdziwe zamówienie nigdy nie dociera do tego pułapu; błędne dociera zawsze. Oba pułapy są ustawieniami (invoice_max_ton, invoice_max_usdt) i można je podnieść dla Twojego sklepu — wystarczy poprosić.
Limit godzinowy i limit otwartych faktur chronią pulę adresów. Każda otwarta faktura zajmuje adres odbiorczy, a niekontrolowana pętla na jednej stronie w przeciwnym razie wyczerpałaby pulę dla wszystkich. Nowy sklep może mieć 20 faktur otwartych jednocześnie; przydział rośnie o jeden za każdą fakturę, którą faktycznie zainkasował, aż do pułapu 200. underpaid liczy się jako otwarta — wciąż trzyma swój adres i czeka na resztę. Anulowanie porzuconej faktury natychmiast zwraca jej adres. Ponowienia z tym samym idempotency_key nie są wliczane do limitu godzinowego.
Limit żądań to 120 na minutę na klucz API — dwa wywołania na sekundę, znacznie powyżej jakiegokolwiek realnego strumienia zamówień. 429 niesie nagłówek Retry-After w sekundach: odczekaj tyle, zamiast ponawiać w ciasnej pętli, co tylko odsuwa okno dalej.
Błędy#
Kody statusu, które faktycznie zobaczysz.
Błędy wracają jako JSON, w dwóch postaciach. Wszystko, co rozstrzygamy my albo rdzeń przetwarzający, umieszcza pod kluczem detail parę {code, message}. Treść żądania, która nie przejdzie walidacji, umieszcza tam zamiast tego listę błędów pól. Sprawdź, którą postać dostałeś, zanim odczytasz detail.code — i rozgałęziaj się po `code`, nigdy po `message`: brzmienie może się zmienić w każdej chwili, kod nie.
{
"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
}
]
}| Status | Kiedy | Co zrobić |
|---|---|---|
| 401 | Klucz brakujący, błędny lub unieważniony. | Sprawdź nagłówek. Wygeneruj klucz ponownie, jeśli został unieważniony. |
| 404 | Nie ma takiej faktury albo należy do innego sklepu. | Sprawdź id. Oba przypadki odpowiadają tak samo celowo, żeby nie dało się sondować identyfikatorów. |
| 409 | Faktura znajduje się w stanie, który to zabrania. | Najpierw odczytaj jej bieżący status. |
| 422 | Żądanie jest błędnie zbudowane albo kwota wykracza poza granice faktury. | Komunikat wymienia zarówno przesłaną wartość, jak i limit. |
| 429 | Zbyt wiele faktur w tej godzinie, zbyt wiele otwartych naraz albo zbyt wiele żądań. | Odczekaj Retry-After, potem ponów. |
| 502 | Nie udało się połączyć z rdzeniem przetwarzającym. | Spróbuj ponownie z tym samym kluczem idempotencji. |
Kody
Postać rozstrzygana przez nas to {"detail": {"code": …, "message": …}}. To są kody zwracane przez API dla sprzedawcy.
| Kod | Status | Znaczenie |
|---|---|---|
| invalid_api_key | 401 | Klucz brakujący, błędnie zbudowany, nieznany albo unieważniony. Wszystkie cztery przypadki odpowiadają tak samo, więc klucza nie da się wysondować. |
| not_found | 404 | Nie ma takiego obiektu albo należy on do innego sklepu. |
| invalid_input | 422 | Żądanie nie przeszło walidacji w rdzeniu — błędna kwota, za dużo miejsc po przecinku, kwota poza granicami faktury. |
| conflict | 409 | Działanie stoi w sprzeczności z bieżącym stanem, na przykład anulowanie faktury, która nie jest już otwarta. |
| too_many_requests | 429 | Limit częstotliwości: faktur na godzinę, otwartych faktur albo żądań na minutę. Retry-After mówi, ile czekać. |
| cbc_unreachable | 502 | Nie udało się połączyć z rdzeniem przetwarzającym. Ponów z tym samym idempotency_key. |
| webhook_url_rejected | 422 | Tylko przy zapisie klucza: adres webhooka nie przeszedł opisanych wyżej kontroli. detail.reason wskazuje, która to reguła — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials i tak dalej. |
502 nie oznacza, że faktura nie została utworzona — żądanie mogło przejść, a odpowiedź zgubić się w drodze powrotnej. Spróbuj ponownie z tym samym idempotency_key, a otrzymasz albo istniejącą fakturę, albo nową — nigdy dwie.
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.
Zwroty#
Jak zwrócić pieniądze klientowi.
Zwrot realizowany jest przez wsparcie, a nie wywołaniem API. Zwrot to nowy przelew na adres podany przez człowieka, a operator płatności, który odsyła pieniądze automatycznie na wywołanie API, to operator, którego da się nakłonić do wysłania pieniędzy na adres atakującego. Dlatego jest to celowo ręczne.
Aby zwrócić pieniądze kupującemu, załóż zgłoszenie do wsparcia z panelu klienta, podając invoice_id albo tx_hash, kwotę i adres docelowy. Operator sprawdza płatność, przenosi pieniądze z Twojego salda i odpowiada w tym samym zgłoszeniu. Licz się z dniem roboczym, a nie z minutą.
Dwie konsekwencje, pod które warto zaprojektować przepływ. Nadpłata jest księgowana Ci w całości — nic z niej nie zatrzymujemy — więc zwrot różnicy kupującemu, który wysłał za dużo, jest Twoją decyzją i idzie tą samą drogą. Oraz niedopłacona faktura nie jest sprawą zwrotu, dopóki pozostaje otwarta: pieniądze są na Twoim saldzie, adres wciąż jest obserwowany, a kupujący może po prostu dopłacić. Dopiero po okresie karencji, gdy faktura przechodzi w expired z pieniędzmi na koncie, jest o czym decydować.
Testowanie#
Jak sprawdzić integrację przed startem.
Klucze są tu produkcyjne: każdy wydany klucz to klucz sk_live_ działający na rdzeniu produkcyjnym i sieci głównej TON. Osobnego środowiska testowego nie ma, co ma swój plus: przechodzisz dokładnie tę drogę, którą pójdą prawdziwe zamówienia.
Testuj więc tak, jak testowałbyś cokolwiek, co dotyka prawdziwych pieniędzy: na małych kwotach. Utwórz fakturę na minimum (0.1 TON albo 3 USDT), opłać ją z własnego portfela i obejrzyj całą drogę — stronę płatności, webhooka, weryfikację podpisu, przejście Twojego zamówienia w stan opłacony. Prowizja obowiązuje, a monety naprawdę się przemieszczają.
Rzeczy, które przećwiczysz, nic nie wydając: utworzenie i odczyt faktury, jej anulowanie, 422 przy błędnej kwocie, 401 przy złym kluczu oraz Twoja własna weryfikacja podpisu — podpisz przykładową treść swoim sekretem i podaj ją własnemu handlerowi. Naprawdę prawdziwej płatności wymaga tylko ostatni krok: faktyczny webhook payment.credited.
Zaplanuj integrację tak, by nie zależała od piaskownicy ani symulowanej płatności: prawdziwą ścieżkę sprawdzisz szybciej — i uczciwiej.
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.
Lista kontrolna przed uruchomieniem#
Dziesięć rzeczy do sprawdzenia przed startem.
- Klucz jest wyłącznie po stronie serwera, nigdy w JavaScripcie w przeglądarce.
- Podpis webhooka jest weryfikowany względem
"{timestamp}.{raw_body}", w stałym czasie. - Dostawy starsze niż pięć minut są odrzucane, a zegar serwera chodzi na NTP.
- Powtórzony
X-Paysell-Event-Idza drugim razem niczego nie robi. - Webhook odpowiada 2xx w ciągu dziesięciu sekund; wolna robota dzieje się potem.
- Adres webhooka to domena https:// na porcie 443, bez przekierowania przed nią.
- Nieodebrany webhook nie jest katastrofą: endpoint faktury jest odczytywany na stronie z podziękowaniem albo przy przeglądzie uzgadniającym.
idempotency_keyjest generowany raz na zamówienie i używany ponownie przy ponowieniach.- Kwoty wychodzą w jednostkach normalnych jako łańcuchy znaków; liczby z webhooków są czytane jako najmniejsze jednostki.
- Adres jest wyświetlany dokładnie tak, jak został zwrócony, bez modyfikacji.
overpaidiunderpaidsą obsłużone, nie tylkopaid;expiredmoże wciąż nieśćpaid_minor.- Towar jest wydawany przy
status: paidalbooverpaid, nigdy na samo nadejście wywołania. 429jest obsługiwane przez odczekanieRetry-After, a nie natychmiastowe ponowienie.- Salda są odczytywane od nas, a nie prowadzone osobno jako źródło prawdy.
Coś jest niejasne?
Jeśli ta strona nie odpowiedziała na Twoje pytanie, to luka w dokumentacji, o której warto nam powiedzieć. Napisz z poziomu panelu klienta, a my poprawimy stronę, a nie tylko odpowiedź.