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.
Cum funcționează o plată#
Șase pași, majoritatea de partea noastră.
Șase pași, majoritatea de partea noastră:
- 1
Clientul dvs. apasă pe plată
Serverul dvs. apelează API-ul nostru cu suma și propria referință a comenzii.
- 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
Clientul trimite monede
Scanează codul QR sau copiază adresa. Trimiteți-i la
payment_urlpe care îl returnăm, iar pagina este gestionată pentru dvs. — sumă, adresă, cod QR, numărătoare inversă, status live. - 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
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
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.
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 USDTSuprasplata 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ă.
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
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
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
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
Trimiteți cumpărătorul la
payment_urlAcesta 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
Așteptați webhookul
Odată ce banii sunt confirmați on-chain și creditați, trimitem un POST cu evenimentul semnat
payment.creditedcătre serverul dvs. Verificați semnătura, apoi marcați comanda drept plătită — dar numai cânddata.statusestepaidsauoverpaid. 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
- 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.
Autentificare#
Cheia dvs. API, și cum este folosită.
Fiecare cerere poartă cheia dvs. în antetul Authorization:
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxAFiecare 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ă.
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.
Creați o factură#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoicesCorpul cererii
| Câmp | Tip | Obligatoriu | Descriere |
|---|---|---|---|
| asset | string | da | Fie TON, fie USDT_TON. |
| amount | string | da | Unități normale ale monedei, ca șir: "5" înseamnă 5 USDT. Nu mai multe zecimale decât are moneda. Vezi Sume. |
| order_id | string | nu | Referința dvs. proprie, până la 200 de caractere. Revine în fiecare webhook — așa asociați o plată cu o comandă. |
| description | string | nu | Până la 1000 de caractere. Afișată cumpărătorului pe pagina de plată. |
| ttl_minutes | number | nu | Câ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_key | string | nu | Pâ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
{
"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âmp | Ce faceți cu el |
|---|---|
| invoice_id | Stocați-l alături de comanda dvs. Este ceea ce identifică plata peste tot altundeva. |
| payment_url | Redirecționați cumpărătorul aici. Nimic altceva de construit. |
| address | Doar dacă vă construiți propria pagină de checkout. Afișați-l exact așa cum a fost primit — vezi avertismentul de mai jos. |
| amount | Suma în unități normale, exact cum ai trimis-o. Pe aceasta o afișezi. |
| amount_minor | Aceeași sumă ca număr întreg în unitatea minimă. Cu aceasta calculezi. |
| expires_at | Afișați o numărătoare inversă. După ce trece, adresa nu mai este monitorizată pentru această factură. |
| status | Aici întotdeauna pending. Schimbările reale sosesc prin webhook. |
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}
/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
/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
| Eveniment | Când | Ce conține corpul |
|---|---|---|
| payment.credited | Transferul este confirmat în rețea, comisionul nostru a fost reținut, iar restul se află în soldul dvs. | Câmpurile enumerate mai jos. |
| payment.rejected | O 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
{
"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"
}
}Corespondența câmpurilor
| Câmp | Semnificație |
|---|---|
| event_id | Unic 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_id | Referința dvs. Căutați comanda după aceasta. |
| data.amount | Cât a trimis cumpărătorul în acest transfer, în unitatea minimă — spre deosebire de API, care primește unități normale. |
| data.fee | Cât am reținut, în unitatea minimă. |
| data.credited | Cât a ajuns în soldul dvs.: amount − fee, în unitatea minimă. |
| data.paid_minor | Totalul 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.asset | Moneda care a sosit efectiv. Nu neapărat moneda cerută de factură. |
| data.asset_mismatch | Apare, 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_asset | Vine împreună cu asset_mismatch: moneda pe care o cere de fapt factura. |
| data.status | Statusul de acum al facturii: pending, underpaid, paid, overpaid sau expired. Comparați cu ce ați așteptat. |
| data.tx_hash | Tranzacția on-chain, pentru evidențele dvs. și suport. |
Anteturi la fiecare livrare
X-Paysell-Event: payment.credited
X-Paysell-Event-Id: 99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp: 1789000000
X-Paysell-Signature: sha256=6f1c0e6a…| Antet | Semnificație |
|---|---|
| X-Paysell-Event | Tipul evenimentului: payment.credited sau payment.rejected. |
| X-Paysell-Event-Id | Unic pentru fiecare eveniment. Aceasta este valoarea după care faceți deduplicarea. |
| X-Paysell-Timestamp | Momentul în care am semnat, în secunde unix. Face parte din șirul semnat. |
| X-Paysell-Signature | sha256= 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:
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)
}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,.lansau.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.1nu 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
Locationnu.
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.
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.
Referință statusuri#
Fiecare status de factură și de plată, explicat.
Factură
| Status | Semnificație | Ce faceți |
|---|---|---|
| pending | Se așteaptă plata. | Păstrați comanda deschisă. |
| paid | Plătită integral. | Eliberați bunurile. |
| overpaid | A sosit mai mult decât s-a cerut. Surplusul vă este creditat integral. | Eliberați bunurile; rambursați diferența dacă doriți. |
| underpaid | A 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ă. |
| expired | Fereastra 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. |
| cancelled | Anulată 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.
| Status | Semnificație |
|---|---|
| detected | Văzută on-chain, se așteaptă confirmări. |
| confirmed | Rețeaua a confirmat-o. Urmează creditarea. |
| credited | În soldul dvs. Acesta este momentul în care se declanșează webhookul. |
| review | Reținută pentru o verificare suplimentară — de exemplu, monede sosite la o adresă fără factură deschisă. |
| rejected | Nu 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ă.
| Activ | Zecimale | Tu trimiți | amount_minor în răspuns |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "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.
// 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. 1500000nLimite#
Minime, maxime și limite de frecvență.
| Limită | Valoare | La încălcare |
|---|---|---|
| Factură minimă | 0.1 TON · 3 USDT | 422 |
| Factură maximă | 7000 TON · 10000 USDT | 422 |
| Facturi pe oră, pe magazin | 60 | 429 |
| Facturi deschise simultan | 20, crescând cu fiecare factură plătită, până la 200 | 429 |
| Durata de viață a facturii | 1 minut – 24 de ore (implicit 2 ore) | 422 |
| Cereri API per cheie | 120 pe minut | 429 + 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.
{
"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 | Când | Ce faceți |
|---|---|---|
| 401 | Cheie lipsă, greșită sau revocată. | Verificați antetul. Reemiteți cheia dacă a fost revocată. |
| 404 | Nu 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. |
| 409 | Factura se află într-o stare care interzice aceasta. | Citiți mai întâi statusul curent. |
| 422 | Cererea este malformată sau suma este în afara limitelor facturii. | Mesajul indică atât valoarea trimisă, cât și limita. |
| 429 | Prea multe facturi în această oră, prea multe deschise simultan, sau prea multe cereri. | Așteptați expirarea Retry-After, apoi reîncercați. |
| 502 | Nu 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.
| Cod | Status | Semnificație |
|---|---|---|
| invalid_api_key | 401 | Cheia 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_found | 404 | Nu există un astfel de obiect, sau aparține altui magazin. |
| invalid_input | 422 | Cererea nu a trecut validarea în nucleu — o sumă greșită, prea multe zecimale, o sumă în afara limitelor facturii. |
| conflict | 409 | Acțiunea contrazice starea curentă, cum ar fi anularea unei facturi care nu mai este deschisă. |
| too_many_requests | 429 | O limită de frecvență: facturi pe oră, facturi deschise, sau cereri pe minut. Retry-After spune cât trebuie așteptat. |
| cbc_unreachable | 502 | Nu am putut ajunge la nucleul de procesare. Reîncercați cu aceeași idempotency_key. |
| webhook_url_rejected | 422 | Doar 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 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.
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.
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.
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-Idrepetat 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_keyeste 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șiunderpaidsunt gestionate, nu doarpaid;expiredpoate purta totușipaid_minor.- Marfa se livrează la
status: paidsauoverpaid, niciodată la simpla sosire a apelului. 429este gestionat așteptând expirareaRetry-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.