Paysell

Acceptați plăți în criptomonede

Paysell decontează TON și USDT pe rețeaua TON. Creați o factură, vă oferim un link, iar dvs. primiți un callback semnat imediat ce banii sunt confirmați on-chain și creditați în soldul dvs.

Prezentare generală#

Ce face Paysell și ce nu face.

Paysell este un procesator de plăți, nu un portofel. Nu gestionați niciodată chei private, nu urmăriți blockchain-ul și nu decideți când o tranzacție este finală — asta este partea pe care ne-o asumăm noi.

Fiecare factură primește propria adresă de încasare. Când cumpărătorul o plătește, așteptăm ca rețeaua să confirme transferul, deducem comisionul nostru și credităm restul în soldul dvs. Puteți retrage către orice adresă doriți.

Soldurile sunt păstrate la noi și reprezintă singura sursă de adevăr. Afișați-le, dar nu păstrați niciodată o a doua copie ca fiind autoritară — două contoare ajung întotdeauna, mai devreme sau mai târziu, să difere, și atunci nimeni nu mai știe care este cel corect.

Cum funcționează o plată#

Șase pași, majoritatea de partea noastră.

Șase pași, majoritatea de partea noastră:

  1. 1

    Clientul dvs. apasă pe plată

    Serverul dvs. apelează API-ul nostru cu suma și propria referință a comenzii.

  2. 2

    Oferim o adresă

    O adresă de încasare nouă este preluată dintr-un fond pre-generat și asociată acestei facturi. O adresă aparține exact unei singure facturi deschise, iar așa se corelează o plată cu ea.

  3. 3

    Clientul trimite monede

    Scanează codul QR sau copiază adresa. Trimiteți-i la payment_url pe care îl returnăm, iar pagina este gestionată pentru dvs. — sumă, adresă, cod QR, numărătoare inversă, status live.

  4. 4

    Detectăm transferul

    Sunt interogate două surse independente de date blockchain, iar răspunsurile lor sunt comparate. Dacă nu coincid, ne oprim în loc să alegem răspunsul mai convenabil.

  5. 5

    Așteptăm finalitatea

    Includerea în masterchain plus încă trei blocuri deasupra. Aproximativ cincisprezece secunde — o plată care pare decontată, dar apoi dispare, ar fi pierderea dvs., așa că nu ne asumăm acest risc.

  6. 6

    Creditat, și sunteți anunțat

    Comisionul este dedus, restul ajunge în soldul dvs., iar un webhook semnat este trimis serverului dvs., purtând order_id-ul dvs.

Aproximativ un minut de la plată până la callback: circa cincisprezece secunde pentru confirmările rețelei, restul fiind verificarea noastră periodică a adreselor monitorizate.

Unde ajung banii#

Comisionul, și pe ce se calculează.

Comisionul este de 0,2%, fixat pentru magazinul dvs. în momentul înregistrării. Dacă rata standard se schimbă ulterior, a dvs. nu se schimbă — este scrisă în fiecare factură ca număr, nu ca referință la o setare.

Comisionul se percepe din ceea ce sosește efectiv, nu din ceea ce a solicitat factura. Facturați 5 USDT și primiți 20, comisionul se calculează la 20. Plătiți mai puțin, și se calculează la ce a sosit.

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

Suprasplata este creditată integral — nu păstrăm diferența. Subplata lasă factura deschisă, astfel încât cumpărătorul să poată completa suma către aceeași adresă.

Scoaterea monedelor de pe o adresă de încasare costă gaz de rețea, iar noi îl plătim — acea parte nu vă atinge niciodată soldul. Retragerea către propria adresă este altceva: are propriul comision, reținut din suma solicitată, iar cifrele exacte se află în lista de comisioane.

Start rapid#

Cinci minute până la prima dvs. factură.

Cinci pași. Doi sunt clicuri în zona dvs. de cont, unul este o singură cerere de pe serverul dvs., iar ultimii doi se întâmplă de la sine.

  1. 1

    Creați un magazin

    În zona dvs. de cont. Începe imediat să accepte plăți — fără așteptarea unei verificări. Verificarea are loc discret în fundal și restricționează doar retragerile, nu plățile primite.

  2. 2

    Emiteți o cheie API

    Magazinul dvs. → Chei API → Cheie nouă. Cheia și secretul webhook sunt afișate o singură dată și niciodată din nou. Păstrați-le cum ați păstra o parolă de bază de date și nu le trimiteți niciodată către un browser.

  3. 3

    Creați o factură

    O singură cerere de pe serverul dvs., un singur link primit înapoi. Cele patru exemple de mai jos trimit exact același lucru.

  4. 4

    Trimiteți cumpărătorul la payment_url

    Acesta este întregul checkout — sumă, adresă, cod QR, numărătoare inversă, status live — și nu aveți nimic de construit. Vezi Pagina de plată pentru ce vede efectiv cumpărătorul.

  5. 5

    Așteptați webhookul

    Odată ce banii sunt confirmați on-chain și creditați, trimitem un POST cu evenimentul semnat payment.credited către serverul dvs. Verificați semnătura, apoi marcați comanda drept plătită — dar numai când data.status este paid sau overpaid. Vezi Webhookuri.

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

Redirecționați cumpărătorul către payment_url din răspuns. Ați terminat — restul sosește sub formă de webhook.

What to do next

Autentificare#

Cheia dvs. API, și cum este folosită.

Fiecare cerere poartă cheia dvs. în antetul Authorization:

http
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA

Fiecare cheie emisă aici începe cu sk_live_. Prefixul sk_test_ există doar pe o instalare îndreptată către rețeaua de test, iar o astfel de instalare nu este oferită — vezi Testare. Stocăm un hash unidirecțional, nu cheia în sine, așa că nimeni, nici noi, nu v-o poate arăta din nou. Ați pierdut-o? Emiteți una nouă și revocați-o pe cea veche.

Magazinul este derivat din cheie, motiv pentru care nicio cerere nu preia vreodată un id de magazin. O cheie poate acționa doar asupra propriului magazin.

Calea conține o versiune: /api/merchant/v1/…. În interiorul unei versiuni doar adăugăm câmpuri — nimic nu este redenumit și nimic nu își schimbă sensul pe tăcute. O schimbare care ți-ar strica codul primește un prefix nou, /v2, iar /v1 continuă să funcționeze o perioadă anunțată.

Această cheie creează facturi în numele dvs. Păstrați-o pe server. Orice se află în JavaScript de browser este public, indiferent cât de bine pare ascuns.

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.

Creați o factură#

POST /api/merchant/v1/invoices

POST/api/merchant/v1/invoices

Corpul cererii

CâmpTipObligatoriuDescriere
assetstringdaFie TON, fie USDT_TON.
amountstringdaUnități normale ale monedei, ca șir: "5" înseamnă 5 USDT. Nu mai multe zecimale decât are moneda. Vezi Sume.
order_idstringnuReferința dvs. proprie, până la 200 de caractere. Revine în fiecare webhook — așa asociați o plată cu o comandă.
descriptionstringnuPână la 1000 de caractere. Afișată cumpărătorului pe pagina de plată.
ttl_minutesnumbernuCât timp rămâne factura plătibilă, în minute. 1–1440; omiteți-l și se aplică valoarea implicită — astăzi 2 ore.
idempotency_keystringnuPână la 200 de caractere. Trimiteți aceeași valoare la reîncercare și veți primi înapoi aceeași factură, nu una a doua. Este un câmp din corpul cererii, nu antetul Idempotency-Key — acel antet nu este citit aici.

Răspuns · 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"
}

Cum se corelează cu comanda dvs.

CâmpCe faceți cu el
invoice_idStocați-l alături de comanda dvs. Este ceea ce identifică plata peste tot altundeva.
payment_urlRedirecționați cumpărătorul aici. Nimic altceva de construit.
addressDoar dacă vă construiți propria pagină de checkout. Afișați-l exact așa cum a fost primit — vezi avertismentul de mai jos.
amountSuma în unități normale, exact cum ai trimis-o. Pe aceasta o afișezi.
amount_minorAceeași sumă ca număr întreg în unitatea minimă. Cu aceasta calculezi.
expires_atAfișați o numărătoare inversă. După ce trece, adresa nu mai este monitorizată pentru această factură.
statusAici întotdeauna pending. Schimbările reale sosesc prin webhook.
Dacă vă construiți propria pagină, afișați adresa exact așa cum a fost returnată. Este în formă non-bounceable (UQ… pe mainnet, 0Q… pe testnet). Convertiți-o, înfrumusețați-o sau înlocuiți-o cu o altă codificare a aceleiași adrese, iar monedele trimise către un portofel neimplementat încă se vor întoarce la expeditor.

Citiți o factură#

GET /api/merchant/v1/invoices/{invoice_id}

GET/api/merchant/v1/invoices/{invoice_id}

Aceeași structură ca mai sus, cu status, paid și paid_minor reflectând prezentul: paid arată cât a sosit în unități normale, iar paid_minor același lucru ca număr întreg în unitatea minimă. Util ca soluție de rezervă atunci când un webhook a fost ratat, sau pe o pagină de mulțumire.

Interogați-l cel mult o dată la câteva secunde și tratați webhookurile ca fiind canalul principal. Facturile care aparțin altui magazin răspund cu 404 — nu 403, astfel încât un id să nu poată fi sondat pentru existență.

Anulați o factură#

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

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

Închide o factură încă deschisă — pending sau underpaid — și eliberează adresa acesteia. Folosiți-o atunci când clientul abandonează checkout-ul: adresele sunt o resursă limitată, iar returnarea lor menține fondul sănătos.

O factură care nu mai este deschisă răspunde cu 409. Anularea uneia underpaid nu returnează monede nimănui: banii deja creditați rămân în soldul dvs., iar tot ce se închide este posibilitatea de completare a sumei.

Webhookuri#

Ce sosește și cum se verifică.

Setați un URL de webhook când creați cheia. Trimitem un POST acolo când o plată este creditată — și când o depunere reținută pentru o verificare suplimentară este respinsă. Fiecare livrare este semnată, iar noi reîncercăm timp de aproximativ o zi și jumătate până când răspundeți 2xx. Livrați marfa la status: paid sau overpaid, nu la simpla sosire a apelului.

Evenimente

EvenimentCândCe conține corpul
payment.creditedTransferul este confirmat în rețea, comisionul nostru a fost reținut, iar restul se află în soldul dvs.Câmpurile enumerate mai jos.
payment.rejectedO depunere reținută pentru o verificare suplimentară (vezi Referință statusuri) a fost respinsă. Banii nu vor ajunge în soldul dvs.invoice_id, order_id, asset, amount, tx_hash și reason. Nu livrați marfa; dacă factura era deja paid dintr-un transfer anterior, acest eveniment se referă la depozitul suplimentar, nu la acea plată.

Ce sosește

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

Corespondența câmpurilor

CâmpSemnificație
event_idUnic pentru fiecare eveniment; se află și în antetul X-Paysell-Event-Id. Stocați-l și ignorați repetările — vezi mai jos.
data.order_idReferința dvs. Căutați comanda după aceasta.
data.amountCât a trimis cumpărătorul în acest transfer, în unitatea minimă — spre deosebire de API, care primește unități normale.
data.feeCât am reținut, în unitatea minimă.
data.creditedCât a ajuns în soldul dvs.: amount − fee, în unitatea minimă.
data.paid_minorTotalul primit până acum pe această factură, în unitatea minimă. Câmpul care contează la underpaid: statusul spune că a sosit mai puțin, acesta spune cu cât mai puțin.
data.assetMoneda care a sosit efectiv. Nu neapărat moneda cerută de factură.
data.asset_mismatchApare, cu valoarea true, doar când moneda sosită nu este cea a facturii. Banii vă sunt creditați, dar factura rămâne neplătită, iar status nu va fi niciodată paid.
data.invoice_assetVine împreună cu asset_mismatch: moneda pe care o cere de fapt factura.
data.statusStatusul de acum al facturii: pending, underpaid, paid, overpaid sau expired. Comparați cu ce ați așteptat.
data.tx_hashTranzacția on-chain, pentru evidențele dvs. și suport.

Anteturi la fiecare livrare

http
X-Paysell-Event:      payment.credited
X-Paysell-Event-Id:   99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp:  1789000000
X-Paysell-Signature:  sha256=6f1c0e6a…
AntetSemnificație
X-Paysell-EventTipul evenimentului: payment.credited sau payment.rejected.
X-Paysell-Event-IdUnic pentru fiecare eveniment. Aceasta este valoarea după care faceți deduplicarea.
X-Paysell-TimestampMomentul în care am semnat, în secunde unix. Face parte din șirul semnat.
X-Paysell-Signaturesha256= urmat de HMAC în hexazecimal. Vezi mai jos.

Verificarea semnăturii

Fiecare cerere este semnată cu secretul webhook afișat o singură dată, atunci când ați creat cheia. Semnătura este HMAC-SHA256(secret, "{timestamp}.{raw_body}") — marca temporală din X-Paysell-Timestamp, un punct literal, apoi octeții corpului. Verificați-o înainte de a acționa: fără asta, oricine află URL-ul dvs. vă poate preda o comandă plătită.

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

Semnați octeții bruți ai corpului, exact așa cum au fost primiți. Analizați JSON-ul și reserializați-l, iar octeții se schimbă — ordinea cheilor, spațierea — și semnătura nu se va mai potrivi. Comparați în timp constant (hmac.compare_digest, crypto.timingSafeEqual): un == simplu returnează mai repede când primul octet este greșit, iar diferența aceasta este suficientă pentru a ghici o semnătură octet cu octet.

Fereastra mărcii temporale

Respingeți orice are marca temporală la mai mult de cinci minute distanță față de propriul dvs. ceas, în oricare direcție. Marca temporală se află în interiorul șirului semnat tocmai pentru a nu putea fi modificată fără a strica semnătura; fereastra este cea care transformă asta în protecție. Fără ea, o cerere interceptată o dată rămâne valabilă pentru totdeauna și poate fi reluată oricând — semnătura singură nu expiră niciodată. Țineți ceasul serverului pe NTP, altfel această verificare va începe să respingă livrări bune.

Duplicate

Același eveniment poate sosi de mai multe ori. Nu este un bug: reîncercăm până când răspundeți 2xx, iar o livrare care a reușit, dar al cărei răspuns nu ne-a ajuns niciodată, este trimisă din nou. Înregistrați X-Paysell-Event-Id (vine și ca event_id în corp) și asigurați-vă că a doua sosire nu face nimic.

Reîncercări

Prima încercare pleacă imediat ce plata este creditată. Dacă eșuează — expirare, conexiune refuzată, eroare TLS, o redirecționare sau orice status care nu este 2xx — reîncercăm după un program fix:

1 min → 5 min → 15 min → 1 h → 6 h → 24 h

Șapte încercări în total, întinse pe aproximativ 31 de ore. Primele sunt apropiate pentru că motivul obișnuit este un receptor care tocmai repornea și este deja înapoi; ultimele sunt rare pentru că nu ajută pe nimeni să bombardezi un server care este căzut de o zi.

După ultima încercare, livrarea este marcată dropped și ne oprim singuri. Nu este pierdută: rândul plății din zona dvs. de cont arată starea, numărul de încercări și clasa erorii, cu un buton Trimite din nou care pornește o serie nouă, cu toate cele șapte încercări. Cealaltă cale de rezolvare este GET /api/merchant/v1/invoices/{invoice_id} — factura își știe întotdeauna propriul status.

Cum trebuie să arate un URL de webhook

URL-ul este verificat când îl salvați și din nou înaintea fiecărei livrări. Un URL care nu trece verificarea primește ca răspuns 422 și code: "webhook_url_rejected" la salvare, iar dacă începe să eșueze mai târziu, marchează livrarea failed — fără reîncercări. Regulile:

  • Doar `https://`, și portul 443. Un webhook transportă detalii de plată; în http simplu, acestea pot fi citite de oricine se află pe traseu.
  • Un nume de domeniu, nu o adresă IP. Aveți nevoie oricum de un certificat, iar certificatele nu se emit pentru IP-uri simple.
  • Fără `localhost`, și fără nume .local, .internal, .corp, .lan sau .test — serverele noastre nu pot ajunge în rețeaua dvs., iar un nume care se rezolvă în interiorul rețelei noastre este exact ce nu trebuie să apelăm.
  • Fără credențiale în URL (https://user:pass@…). Puneți-vă propriul token în cale sau într-un parametru de interogare, dacă aveți nevoie de unul.
  • Fiecare adresă la care se rezolvă numele trebuie să fie publică — și A, și AAAA. Intervalele private, loopback, link-local și CGNAT sunt refuzate, iar verificarea se repetă înaintea fiecărei livrări, așa că nici îndreptarea ulterioară a înregistrării către 127.0.0.1 nu funcționează.
  • O redirecționare este un eșec, nu un salt. Nu le urmăm: adresa pe care ne-ați dat-o a fost verificată, cea dintr-un antet Location nu.
Verificarea are loc de două ori intenționat — o dată când salvați URL-ul, ca o greșeală de tastare să primească răspuns imediat, nu prin nelivrare tăcută, și o dată înaintea fiecărei trimiteri, pentru că proprietarul unui domeniu îl poate reorienta oricând către o adresă internă. Dacă endpointul dvs. se mută, actualizați mai întâi cheia: un URL respins nu livrează nimic și nu intră în coadă.

Răspundeți rapid

Orice 2xx este suficient, în decurs de zece secunde — acesta este tot timpul nostru de expirare, inclusiv conexiunea. Răspundeți întâi, munca lentă o faceți după; un endpoint care așteaptă propria bază de date înainte de a răspunde va fi înregistrat, mai devreme sau mai târziu, ca expirare și reîncercat, iar dvs. veți procesa același eveniment de două ori. Orice altceva — un 4xx, un 5xx, o redirecționare, o blocare — contează ca încercare eșuată și intră înapoi în programul de mai sus.

Livrarea, sincer

Ceea ce este garantat este mecanismul de livrare: șapte încercări pe parcursul a aproximativ 31 de ore, o retrimitere manuală din zona dvs. de cont și un endpoint de factură care știe întotdeauna statusul real. Construiți fluxul astfel încât un webhook care nu sosește niciodată să nu vă coste nimic — citiți factura pe pagina de mulțumire sau reconciliați facturile deschise o dată pe oră. Webhookurile sunt calea rapidă, nu singura cale.

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.

Referință statusuri#

Fiecare status de factură și de plată, explicat.

Factură

StatusSemnificațieCe faceți
pendingSe așteaptă plata.Păstrați comanda deschisă.
paidPlătită integral.Eliberați bunurile.
overpaidA sosit mai mult decât s-a cerut. Surplusul vă este creditat integral.Eliberați bunurile; rambursați diferența dacă doriți.
underpaidA sosit mai puțin decât s-a cerut. Factura rămâne deschisă și își păstrează adresa: cumpărătorul poate completa suma la același loc, iar paid_minor arată cât a intrat deja. Rămâne plătibilă pe tot restul duratei sale de viață, plus o perioadă de grație de 24 de ore după expires_at.Așteptați completarea sumei sau ajungeți la o înțelegere cu clientul. Nu eliberați bunurile — factura nu este plătită.
expiredFereastra s-a închis, inclusiv perioada de grație. Poate conține totuși bani: ce a sosit a rămas în soldul dvs., iar paid_minor arată cât.Oferiți o factură nouă. Nu acceptați plata către adresa veche: odată ce o factură expiră, adresa se întoarce în fond, iar un transfer foarte întârziat este un caz pentru suport, nu o creditare automată. Verificați paid_minor înainte de a-i spune clientului că nu s-a primit nimic.
cancelledAnulată de dvs. Adresa este eliberată înapoi în fond.Nimic.

Plată

Vizibil în zona dvs. de cont; util atunci când asistați un client în timpul plății.

StatusSemnificație
detectedVăzută on-chain, se așteaptă confirmări.
confirmedRețeaua a confirmat-o. Urmează creditarea.
creditedÎn soldul dvs. Acesta este momentul în care se declanșează webhookul.
reviewReținută pentru o verificare suplimentară — de exemplu, monede sosite la o adresă fără factură deschisă.
rejectedNu a fost creditată. Motivul este înregistrat.

Când o plată ajunge la `review`

Unele depuneri sunt reținute pentru o verificare suplimentară, în loc să fie creditate imediat: o sumă neobișnuit de mare, monede sosite la o adresă fără factură deschisă, sau cele două surse de date blockchain pe care le interogăm care nu sunt de acord asupra a ceea ce s-a întâmplat. Nimic nu se pierde — banii așteaptă o decizie, iar webhookul se declanșează imediat ce aceasta există, ceea ce poate însemna minute sau ore. Tratați lipsa unui apel pentru o plată afișată ca review drept normală, nu ca pe un eșec. Dacă are importanță pentru o comandă, întrebați suportul și menționați tx_hash.

Sume#

Unități normale la trimitere, unități minime la întoarcere.

Trimite sumele în unitățile normale ale monedei, ca șir de caractere"1.5" înseamnă unu și jumătate. Nu un număr JSON și nu unitatea minimă.

ActivZecimaleTu trimițiamount_minor în răspuns
TON9"1.5""1500000000"
USDT_TON6"1.5""1500000"

Un șir, nu un număr, pentru că numerele JSON sunt double IEEE-754 și o sumă mare în nanotoni nu mai încape exact în el. Mai multe zecimale decât are moneda înseamnă 422, niciodată o rotunjire tăcută a banilor tăi. La webhook-uri e invers: acolo amount, fee și credited sunt numere întregi în unitatea minimă, pentru că partea aceea e citită de cod, nu de un om.

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

Limite#

Minime, maxime și limite de frecvență.

LimităValoareLa încălcare
Factură minimă0.1 TON · 3 USDT422
Factură maximă7000 TON · 10000 USDT422
Facturi pe oră, pe magazin60429
Facturi deschise simultan20, crescând cu fiecare factură plătită, până la 200429
Durata de viață a facturii1 minut – 24 de ore (implicit 2 ore)422
Cereri API per cheie120 pe minut429 + Retry-After

Minimul nu este birocrație. Comisionul nostru este un procent, dar încasarea unei plăți costă o sumă fixă: mutarea USDT de pe o adresă de încasare înseamnă mai întâi alimentarea ei cu gaz, din buzunarul nostru. Sub câțiva dolari, comisionul nu acoperă manipularea, iar acceptarea unei astfel de plăți ar însemna să vă credităm bani a căror mutare este neeconomică.

Maximul nu este despre magazinele mari — este o capcană pentru greșeala de unități. Trimiteți "5000000" unde ați vrut "5" și altfel ați obține o factură de cinci milioane de dolari: cumpărătorul vede o sumă absurdă și pleacă. O comandă reală nu atinge niciodată acest plafon; una greșită, întotdeauna. Ambele plafoane sunt setări (invoice_max_ton, invoice_max_usdt) și pot fi ridicate pentru magazinul dvs. — cereți.

Plafonul orar și plafonul facturilor deschise protejează amândouă fondul de adrese. Fiecare factură deschisă ocupă o adresă de încasare, iar o buclă scăpată de sub control pe un singur site ar epuiza altfel fondul pentru toată lumea. Un magazin nou poate ține 20 de facturi deschise simultan; permisiunea crește cu una pentru fiecare factură pe care a încasat-o efectiv, până la un plafon de 200. underpaid contează ca deschisă — încă își ține adresa, așteptând restul. Anularea unei facturi abandonate îi returnează adresa imediat. Reîncercările cu aceeași idempotency_key nu se contorizează la plafonul orar.

Limita de cereri este de 120 pe minut per cheie API — două apeluri pe secundă, cu mult peste orice flux real de comenzi. Un 429 poartă un antet Retry-After în secunde: așteptați atât, în loc să reîncercați într-o buclă strânsă, ceea ce nu face decât să împingă fereastra mai departe.

Erori#

Codurile de status pe care le veți vedea efectiv.

Erorile revin ca JSON, în două forme. Orice hotărâm noi sau nucleul de procesare pune o pereche {code, message} sub detail. Un corp de cerere care nu trece validarea pune acolo, în schimb, o listă de erori pe câmpuri. Verificați pe care dintre ele ați primit-o înainte de a citi detail.code — și ramificați după `code`, niciodată după `message`: formularea se poate schimba oricând, codul nu.

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
    }
  ]
}
StatusCândCe faceți
401Cheie lipsă, greșită sau revocată.Verificați antetul. Reemiteți cheia dacă a fost revocată.
404Nu există o astfel de factură, sau aparține altui magazin.Verificați id-ul. Cele două cazuri răspund la fel în mod intenționat, astfel încât un id să nu poată fi sondat.
409Factura se află într-o stare care interzice aceasta.Citiți mai întâi statusul curent.
422Cererea este malformată sau suma este în afara limitelor facturii.Mesajul indică atât valoarea trimisă, cât și limita.
429Prea multe facturi în această oră, prea multe deschise simultan, sau prea multe cereri.Așteptați expirarea Retry-After, apoi reîncercați.
502Nu am putut ajunge la nucleul de procesare.Reîncercați cu aceeași cheie de idempotență.

Coduri

Forma hotărâtă de noi este {"detail": {"code": …, "message": …}}. Acestea sunt codurile returnate de API-ul pentru comercianți.

CodStatusSemnificație
invalid_api_key401Cheia lipsește, este malformată, necunoscută sau revocată. Toate cele patru cazuri răspund la fel, astfel încât o cheie să nu poată fi sondată.
not_found404Nu există un astfel de obiect, sau aparține altui magazin.
invalid_input422Cererea nu a trecut validarea în nucleu — o sumă greșită, prea multe zecimale, o sumă în afara limitelor facturii.
conflict409Acțiunea contrazice starea curentă, cum ar fi anularea unei facturi care nu mai este deschisă.
too_many_requests429O limită de frecvență: facturi pe oră, facturi deschise, sau cereri pe minut. Retry-After spune cât trebuie așteptat.
cbc_unreachable502Nu am putut ajunge la nucleul de procesare. Reîncercați cu aceeași idempotency_key.
webhook_url_rejected422Doar la salvarea unei chei: URL-ul de webhook nu a trecut verificările de mai sus. detail.reason indică regula încălcată — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials și așa mai departe.

Un 502 nu înseamnă că factura nu a fost creată — cererea ar fi putut trece cu succes, iar răspunsul s-a pierdut pe drumul de întoarcere. Reîncercați cu aceeași idempotency_key și veți primi fie factura existentă, fie una nouă, niciodată două.

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.

Rambursări#

Cum rambursați un client.

Rambursările se fac prin suport, nu printr-un apel de API. O rambursare este un transfer nou către o adresă furnizată de o persoană, iar un procesator de plăți care trimite banii înapoi automat, la un apel de API, este un procesator de plăți care poate fi determinat să trimită bani către adresa unui atacator. Așa că este intenționat manual.

Ca să rambursați un cumpărător, deschideți un tichet de suport din zona dvs. de cont cu invoice_id sau tx_hash, suma și adresa către care se trimite. Un operator verifică plata, scoate banii din soldul dvs. și răspunde în același tichet. Așteptați-vă ca asta să dureze o zi lucrătoare, nu un minut.

Două consecințe în jurul cărora merită să proiectați. Suprasplata vă este creditată integral — nu păstrăm nimic din ea — așa că returnarea diferenței către un cumpărător care a trimis prea mult este decizia dvs. și urmează același traseu. Și o factură subplătită nu este un caz de rambursare atâta timp cât este încă deschisă: banii sunt în soldul dvs., adresa este în continuare urmărită, iar cumpărătorul poate pur și simplu să completeze suma. Abia după perioada de grație, când factura trece pe expired având bani pe ea, apare o decizie de luat.

Testare#

Cum vă testați integrarea înainte de lansare.

Cheile de aici sunt live: fiecare cheie emisă este o cheie sk_live_ care lucrează cu nucleul de producție și cu mainnetul TON. Nu există un mediu de test separat, iar asta are un avantaj: parcurgeți exact drumul pe care îl vor urma comenzile reale.

Așa că testați cum ați testa orice atinge bani reali: pe sume mici. Creați o factură pentru minim (0.1 TON sau 3 USDT), plătiți-o din propriul portofel și urmăriți tot traseul — pagina de plată, webhookul, verificarea semnăturii, comanda dvs. care trece pe plătită. Comisionul se aplică, iar monedele chiar se mișcă.

Părțile pe care le puteți exersa fără să cheltuiți nimic: crearea și citirea unei facturi, anularea uneia, 422 la o sumă malformată, 401 la o cheie greșită, și propria verificare a semnăturii — semnați un corp de probă cu secretul dvs. și dați-l propriului handler. Ce necesită cu adevărat o plată reală este doar ultimul pas: un webhook payment.credited autentic.

Planificați integrarea astfel încât să nu depindă de un sandbox sau de o plată simulată: drumul live se verifică mai repede — și mai fidel.

Tratați prima dvs. comandă reală ca pe testul propriu-zis: alegeți o sumă mică, țineți factura deschisă în panou și verificați rândul plății și starea webhookului înainte de a îndrepta clienți reali către el.

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.

Listă de verificare înainte de lansare#

Zece lucruri de verificat înainte de lansare.

  • Cheia este doar pe server, niciodată în JavaScript de browser.
  • Semnătura webhookului este verificată în raport cu "{timestamp}.{raw_body}", în timp constant.
  • Livrările mai vechi de cinci minute sunt respinse, iar ceasul serverului este pe NTP.
  • Un X-Paysell-Event-Id repetat nu face nimic a doua oară.
  • Webhookul răspunde 2xx în decurs de zece secunde; munca lentă are loc ulterior.
  • URL-ul de webhook este un domeniu https:// pe portul 443, fără nicio redirecționare în față.
  • Un webhook ratat nu este o catastrofă: endpointul de factură este citit pe pagina de mulțumire sau la o trecere de reconciliere.
  • idempotency_key este generată o singură dată per comandă și reutilizată la reîncercări.
  • Sumele pleacă drept șiruri în unități normale; cifrele din webhook se citesc ca unități minime.
  • Adresa este afișată exact așa cum a fost returnată, nemodificată.
  • overpaid și underpaid sunt gestionate, nu doar paid; expired poate purta totuși paid_minor.
  • Marfa se livrează la status: paid sau overpaid, niciodată la simpla sosire a apelului.
  • 429 este gestionat așteptând expirarea Retry-After, nu reîncercând imediat.
  • Soldurile sunt citite de la noi, nu urmărite separat ca adevăr.

Ceva neclar?

Dacă această pagină nu a răspuns la întrebarea dvs., asta înseamnă un gol în documentație și merită să ne spuneți. Scrieți-ne din zona de cont și vom repara pagina, nu doar răspunsul.