Paysell

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.

Salda są przechowywane u nas i stanowią jedyne wiarygodne źródło prawdy. Wyświetlaj je, ale nigdy nie trzymaj drugiej kopii jako nadrzędnej — dwa liczniki prędzej czy później się rozjadą i wtedy nikt nie będzie wiedział, który jest właściwy.

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

    Klient klika przycisk płatności

    Twój serwer wywołuje nasze API z kwotą i własnym numerem referencyjnym zamówienia.

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

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

Nadpł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.

Ściągnięcie monet z adresu odbioru kosztuje gaz sieci i płacimy go my — ta część nigdy nie dotyka Twojego salda. Wypłata na własny adres to co innego: ma własną prowizję, potrącaną z żądanej kwoty, a dokładne liczby znajdziesz w cenniku.

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

    Skieruj kupującego na payment_url

    To 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. 5

    Poczekaj na webhooka

    Gdy pieniądze zostaną potwierdzone w blockchainie i zaksięgowane, wysyłamy POST z podpisanym zdarzeniem payment.credited na Twój serwer. Zweryfikuj podpis, a potem oznacz zamówienie jako opłacone — ale tylko wtedy, gdy data.status to paid albo overpaid. 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

Uwierzytelnianie#

Twój klucz API i sposób jego użycia.

Każde żądanie przenosi Twój klucz w nagłówku Authorization:

http
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA

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

Ten klucz tworzy faktury w Twoim imieniu. Trzymaj go po stronie serwera. Wszystko, co znajdzie się w kodzie JavaScript przeglądarki, jest publiczne, bez względu na to, jak dobrze wygląda na ukryte.

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.

Tworzenie faktury#

POST /api/merchant/v1/invoices

POST/api/merchant/v1/invoices

Treść żądania

PoleTypWymaganeOpis
assetstringtakTON albo USDT_TON.
amountstringtakNormalne jednostki monety, jako łańcuch znaków: "5" to 5 USDT. Nie więcej miejsc po przecinku, niż ma moneta. Zobacz Kwoty.
order_idstringnieTwój własny numer referencyjny, do 200 znaków. Wraca w każdym webhooku — dzięki temu dopasowujesz płatność do zamówienia.
descriptionstringnieDo 1000 znaków. Wyświetlane kupującemu na stronie płatności.
ttl_minutesnumbernieJak 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_keystringnieDo 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

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

Odwzorowanie na Twoje zamówienie

PoleCo z nim zrobić
invoice_idZapisz je przy zamówieniu. To ono identyfikuje płatność wszędzie indziej.
payment_urlPrzekieruj tu kupującego. Nie ma nic więcej do zbudowania.
addressTylko jeśli renderujesz własną stronę płatności. Pokaż dokładnie tak, jak otrzymano — zobacz ostrzeżenie poniżej.
amountKwota w normalnych jednostkach, dokładnie tak, jak ją wysłałeś. Tę pokazuj.
amount_minorTa sama kwota jako liczba całkowita w najmniejszej jednostce. Na niej licz.
expires_atPokaż odliczanie. Po upływie tego czasu adres przestaje być monitorowany dla tej faktury.
statusTutaj zawsze pending. Rzeczywiste zmiany przychodzą webhookiem.
Jeśli renderujesz własną stronę, wypisz adres dokładnie tak, jak został zwrócony. Jest w formie niepodlegającej odbiciu (non-bounceable) (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}

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

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

Zamyka 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

ZdarzenieKiedyCo jest w treści
payment.creditedPrzelew został potwierdzony w blockchainie, nasza prowizja została pobrana, a reszta trafiła na Twoje saldo.Pola wymienione poniżej.
payment.rejectedWpł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

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

Mapowanie pól

PoleZnaczenie
event_idUnikalny dla każdego zdarzenia; jest też w nagłówku X-Paysell-Event-Id. Zapisz go i ignoruj powtórzenia — zobacz poniżej.
data.order_idTwój numer referencyjny. Szukaj po nim swojego zamówienia.
data.amountIle kupujący wysłał w tym przelewie, w najmniejszej jednostce — inaczej niż API, które przyjmuje jednostki normalne.
data.feeIle pobraliśmy, w najmniejszej jednostce.
data.creditedIle trafiło na Twoje saldo: amount − fee, w najmniejszej jednostce.
data.paid_minorIle łą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.assetMoneta, która faktycznie przyszła. Niekoniecznie ta, o którą prosiła faktura.
data.asset_mismatchWystę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_assetPrzychodzi razem z asset_mismatch: moneta, której faktura naprawdę wymaga.
data.statusBieżący status faktury: pending, underpaid, paid, overpaid albo expired. Porównaj z tym, czego oczekiwałeś.
data.tx_hashTransakcja on-chain, do Twojej dokumentacji i wsparcia.

Nagłówki przy każdej dostawie

http
X-Paysell-Event:      payment.credited
X-Paysell-Event-Id:   99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp:  1789000000
X-Paysell-Signature:  sha256=6f1c0e6a…
NagłówekZnaczenie
X-Paysell-EventTyp zdarzenia: payment.credited albo payment.rejected.
X-Paysell-Event-IdUnikalny dla każdego zdarzenia. To po tej wartości robi się deduplikację.
X-Paysell-TimestampMoment podpisania, w sekundach uniksowych. Jest częścią podpisywanego ciągu.
X-Paysell-Signaturesha256=, 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:

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

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, .lan czy .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.1 też 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.
Weryfikacja odbywa się dwa razy celowo — raz przy zapisie adresu, żeby literówka dostała odpowiedź od razu, a nie w postaci cichego braku dostaw, i raz przed każdą wysyłką, bo właściciel domeny może w każdej chwili przestawić ją na adres wewnętrzny. Jeśli Twój endpoint się przenosi, najpierw zaktualizuj klucz: odrzucony adres nie dostarcza niczego i nie trafia do kolejki.

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.

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.

Dokumentacja statusów#

Każdy status faktury i płatności, wyjaśniony.

Faktura

StatusZnaczenieCo zrobić
pendingOczekuje na płatność.Trzymaj zamówienie otwarte.
paidOpłacona w całości.Wydaj towar.
overpaidWpł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ę.
underpaidWpł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.
expiredOkno 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.
cancelledAnulowana przez Ciebie. Adres wraca do puli.Nic.

Płatność

Widoczna w panelu klienta; przydatna przy obsłudze klienta w trakcie płatności.

StatusZnaczenie
detectedZauważona w blockchainie, oczekuje na potwierdzenia.
confirmedSieć ją potwierdziła. Następne w kolejce jest zaksięgowanie.
creditedNa Twoim saldzie. W tym momencie uruchamiany jest webhook.
reviewWstrzymana do dodatkowej weryfikacji — na przykład środki, które trafiły na adres bez otwartej faktury.
rejectedNie 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.

AktywoMiejsca dziesiętneWysyłaszamount_minor w odpowiedzi
TON9"1.5""1500000000"
USDT_TON6"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.

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#

Minimum, maksimum i limity częstotliwości.

LimitWartośćPrzy naruszeniu
Minimalna faktura0.1 TON · 3 USDT422
Maksymalna faktura7000 TON · 10000 USDT422
Faktur na godzinę, na sklep60429
Otwartych faktur jednocześnie20, rośnie z każdą opłaconą fakturą, do 200429
Czas życia fakturyod 1 minuty do 24 godzin (domyślnie 2 godziny)422
Żądań API na klucz120 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.

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
    }
  ]
}
StatusKiedyCo zrobić
401Klucz brakujący, błędny lub unieważniony.Sprawdź nagłówek. Wygeneruj klucz ponownie, jeśli został unieważniony.
404Nie 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.
409Faktura 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.
429Zbyt wiele faktur w tej godzinie, zbyt wiele otwartych naraz albo zbyt wiele żądań.Odczekaj Retry-After, potem ponów.
502Nie 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.

KodStatusZnaczenie
invalid_api_key401Klucz brakujący, błędnie zbudowany, nieznany albo unieważniony. Wszystkie cztery przypadki odpowiadają tak samo, więc klucza nie da się wysondować.
not_found404Nie ma takiego obiektu albo należy on do innego sklepu.
invalid_input422Żądanie nie przeszło walidacji w rdzeniu — błędna kwota, za dużo miejsc po przecinku, kwota poza granicami faktury.
conflict409Działanie stoi w sprzeczności z bieżącym stanem, na przykład anulowanie faktury, która nie jest już otwarta.
too_many_requests429Limit częstotliwości: faktur na godzinę, otwartych faktur albo żądań na minutę. Retry-After mówi, ile czekać.
cbc_unreachable502Nie udało się połączyć z rdzeniem przetwarzającym. Ponów z tym samym idempotency_key.
webhook_url_rejected422Tylko 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 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.

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.

Potraktuj pierwsze prawdziwe zamówienie jak właściwy test: wybierz małą kwotę, trzymaj fakturę otwartą w panelu i sprawdź wiersz płatności oraz stan webhooka, zanim skierujesz tam prawdziwych klientów.

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.

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-Id za 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_key jest 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.
  • overpaid i underpaid są obsłużone, nie tylko paid; expired może wciąż nieść paid_minor.
  • Towar jest wydawany przy status: paid albo overpaid, nigdy na samo nadejście wywołania.
  • 429 jest obsługiwane przez odczekanie Retry-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ź.