Paysell

Accetta pagamenti in criptovalute

Paysell regola i pagamenti in TON e USDT sulla rete TON. Crei una fattura, ti forniamo un link e ricevi un callback firmato non appena il denaro è confermato on-chain e accreditato sul tuo saldo.

Panoramica#

Cosa fa Paysell, e cosa non fa.

Paysell è un processore di pagamenti, non un wallet. Non gestisci mai chiavi private, non monitori la blockchain e non decidi quando una transazione è definitiva — questa parte la gestiamo noi.

Ogni fattura ha il proprio indirizzo di ricezione. Quando un acquirente paga, attendiamo che la rete confermi il trasferimento, detraiamo la nostra commissione e accreditiamo il resto sul tuo saldo. Puoi prelevare verso qualsiasi indirizzo desideri.

I saldi risiedono presso di noi e sono l'unica fonte di verità. Mostrali pure, ma non tenere mai una seconda copia come autorevole — due contatori prima o poi divergono, e a quel punto nessuno sa più quale sia quello corretto.

Come funziona un pagamento#

Sei passaggi, la maggior parte a nostro carico.

Sei passaggi, la maggior parte a nostro carico:

  1. 1

    Il cliente clicca su paga

    Il tuo server chiama la nostra API con l'importo e il tuo riferimento d'ordine.

  2. 2

    Forniamo un indirizzo

    Un nuovo indirizzo di ricezione viene preso da un pool pre-generato e associato a questa fattura. Un indirizzo appartiene a un'unica fattura aperta, ed è così che un pagamento viene ricondotto ad essa.

  3. 3

    Il cliente invia le monete

    Scannerizza il QR code o copia l'indirizzo. Indirizzalo verso il payment_url che restituiamo e la pagina è già pronta per te — importo, indirizzo, QR, conto alla rovescia, stato in tempo reale.

  4. 4

    Rileviamo il trasferimento

    Vengono interrogate due fonti indipendenti di dati blockchain e le loro risposte confrontate. Se non concordano, ci fermiamo invece di scegliere la risposta più comoda.

  5. 5

    Attendiamo la finalità

    Inclusione nella masterchain più tre blocchi sopra. Circa quindici secondi — un pagamento che sembra concluso ma poi scompare sarebbe una tua perdita, quindi non corriamo quel rischio.

  6. 6

    Accreditato, e te lo comunichiamo

    La commissione viene detratta, il resto finisce sul tuo saldo e un webhook firmato viene inviato al tuo server con il tuo order_id.

Circa un minuto dal pagamento al callback: circa quindici secondi di conferme di rete, il resto è la nostra scansione degli indirizzi monitorati.

Dove va il denaro#

La commissione, e su cosa viene calcolata.

La commissione è del 0,2%, fissata per il tuo negozio nel momento in cui viene registrato. Se in seguito la tariffa standard cambia, la tua resta invariata — è scritta in ogni fattura come numero, non come riferimento a un'impostazione.

La commissione viene prelevata da ciò che arriva effettivamente, non da quanto richiesto nella fattura. Fatturi 5 USDT e ne ricevi 20, la commissione viene calcolata su 20. In caso di pagamento insufficiente, viene calcolata su quanto è arrivato.

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

Il pagamento in eccesso viene accreditato per intero — non tratteniamo la differenza. Il pagamento insufficiente lascia la fattura aperta affinché l'acquirente possa integrare verso lo stesso indirizzo.

Spostare monete fuori da un indirizzo di ricezione costa gas di rete, e lo paghiamo noi — quella parte non tocca mai il tuo saldo. Un prelievo verso un tuo indirizzo è un'altra cosa: ha una commissione propria, detratta dall'importo che richiedi, e i numeri esatti stanno nel Tariffario.

Guida rapida#

Cinque minuti alla tua prima fattura.

Cinque passaggi. Due sono clic nella tua area cliente, uno è una singola richiesta dal tuo server, e gli ultimi due avvengono da soli.

  1. 1

    Crea un negozio

    Nella tua area cliente. Inizia ad accettare pagamenti immediatamente — nessuna attesa per la revisione. La verifica avviene silenziosamente in background e limita solo i prelievi, non i pagamenti in entrata.

  2. 2

    Genera una chiave API

    Il tuo negozio → Chiavi API → Nuova chiave. La chiave e il segreto del webhook vengono mostrati una sola volta e mai più. Conservali come faresti con la password di un database, e non inviarli mai a un browser.

  3. 3

    Crea una fattura

    Una richiesta dal tuo server, un link in risposta. I quattro esempi qui sotto inviano tutti esattamente la stessa cosa.

  4. 4

    Manda l'acquirente al payment_url

    Questo è tutto il checkout — importo, indirizzo, QR code, conto alla rovescia, stato in tempo reale — e non c'è nulla da costruire. Vedi Checkout per ciò che l'acquirente vede davvero.

  5. 5

    Aspetta il webhook

    Una volta che il denaro è confermato on-chain e accreditato, inviamo in POST un evento payment.credited firmato al tuo server. Verifica la firma, poi segna l'ordine come pagato — ma solo quando data.status è paid o overpaid. Vedi Webhook.

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

Reindirizza l'acquirente al payment_url nella risposta. Hai finito — il resto arriva come webhook.

What to do next

Autenticazione#

La tua chiave API, e come viene usata.

Ogni richiesta porta la tua chiave nell'header Authorization:

http
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA

Ogni chiave emessa qui inizia con sk_live_. Il prefisso sk_test_ esiste solo su un deployment puntato alla rete di test, e nessun deployment del genere viene offerto — vedi Test. Conserviamo un hash unidirezionale, non la chiave stessa, quindi nessuno, noi compresi, può mostrartela di nuovo. L'hai persa? Generane una nuova e revoca la vecchia.

Il negozio viene derivato dalla chiave, motivo per cui nessuna richiesta include mai un id di negozio. Una chiave può agire solo sul proprio negozio.

Il percorso porta una versione: /api/merchant/v1/…. All'interno di una versione aggiungiamo solo campi: nulla viene rinominato né cambia significato in silenzio. Una modifica che romperebbe il tuo codice riceve un nuovo prefisso, /v2, e /v1 continua a funzionare per un periodo annunciato.

Questa chiave crea fatture a tuo nome. Tienila lato server. Qualsiasi cosa nel JavaScript del browser è pubblica, per quanto bene sembri nascosta.

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.

Creare una fattura#

POST /api/merchant/v1/invoices

POST/api/merchant/v1/invoices

Corpo della richiesta

CampoTipoObbligatorioDescrizione
assetstringTON oppure USDT_TON.
amountstringUnità normali della moneta, come stringa: "5" sono 5 USDT. Non più decimali di quanti ne abbia la moneta. Vedi Importi.
order_idstringnoIl tuo riferimento, fino a 200 caratteri. Torna in ogni webhook — è così che colleghi un pagamento a un ordine.
descriptionstringnoFino a 1000 caratteri. Mostrato all'acquirente nella pagina di pagamento.
ttl_minutesnumbernoPer quanto tempo la fattura resta pagabile, in minuti. 1–1440; ometti il campo e vale il valore predefinito — oggi 2 ore.
idempotency_keystringnoFino a 200 caratteri. Invia lo stesso valore quando ritenti e ottieni la stessa fattura invece di una seconda. È un campo del corpo, non l'header Idempotency-Key — quell'header qui non viene letto.

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

Come collegarlo al tuo ordine

CampoCosa farne
invoice_idMemorizzalo insieme al tuo ordine. È ciò che identifica il pagamento ovunque altro.
payment_urlReindirizza qui l'acquirente. Non c'è altro da costruire.
addressSolo se rendi la tua pagina di checkout. Mostralo esattamente come fornito — vedi l'avviso sotto.
amountL'importo in unità normali, esattamente come l'hai inviato. Mostra questo.
amount_minorLo stesso importo come intero nell'unità minima. Calcola con questo.
expires_atMostra un conto alla rovescia. Dopo che è trascorso, l'indirizzo smette di essere monitorato per questa fattura.
statusSempre pending qui. I cambiamenti reali arrivano tramite webhook.
Se rendi la tua pagina, stampa l'indirizzo esattamente come restituito. È in forma non-bounceable (UQ… su mainnet, 0Q… su testnet). Convertirlo, abbellirlo o sostituirlo con un'altra codifica dello stesso indirizzo farà sì che le monete inviate a un wallet non ancora distribuito tornino indietro al mittente.

Leggere una fattura#

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

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

Stessa struttura di sopra, con status, paid e paid_minor che riflettono la situazione attuale: paid è quanto è arrivato in unità normali, paid_minor la stessa cifra come intero nell'unità minima. Utile come fallback quando un webhook è stato mancato, oppure in una pagina di ringraziamento.

Interrogala al massimo ogni pochi secondi, e considera i webhook il canale primario. Le fatture appartenenti a un altro negozio rispondono 404, non 403 — così un id non può essere sondato per verificarne l'esistenza.

Annullare una fattura#

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

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

Chiude una fattura ancora aperta — pending o underpaid — e libera il suo indirizzo. Usalo quando il cliente abbandona il checkout: gli indirizzi sono una risorsa limitata, e restituirli mantiene sano il pool.

Una fattura che non è più aperta risponde 409. Annullare una fattura underpaid non restituisce monete a nessuno: il denaro già accreditato resta sul tuo saldo, e ciò che si chiude è soltanto la possibilità di integrare.

Webhook#

Cosa arriva, e come verificarlo.

Imposta una URL di webhook quando crei la chiave. Ci facciamo un POST quando un pagamento viene accreditato — e quando un versamento trattenuto per un controllo aggiuntivo viene rifiutato. Ogni consegna è firmata, e continuiamo a ritentare per circa un giorno e mezzo finché non rispondi 2xx. Consegna la merce su status: paid o overpaid, non al semplice arrivo della chiamata.

Eventi

EventoQuandoCosa contiene il corpo
payment.creditedIl trasferimento è confermato sulla chain, la nostra commissione è trattenuta e il resto è sul tuo saldo.I campi elencati sotto.
payment.rejectedUn versamento trattenuto per un controllo aggiuntivo (vedi Riferimento stati) è stato rifiutato. Il denaro non arriverà sul tuo saldo.invoice_id, order_id, asset, amount, tx_hash e reason. Non consegnare la merce; se la fattura era già paid per un trasferimento precedente, l'evento riguarda il versamento in più, non quel pagamento.

Cosa arriva

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

Mappatura dei campi

CampoSignificato
event_idUnico per ogni evento; presente anche nell'header X-Paysell-Event-Id. Memorizzalo e ignora le ripetizioni — vedi sotto.
data.order_idIl tuo riferimento. Cerca il tuo ordine con questo.
data.amountQuanto ha inviato l'acquirente in questo trasferimento, nell'unità minima — a differenza dell'API, che accetta unità normali.
data.feeQuanto abbiamo trattenuto, nell'unità minima.
data.creditedQuanto è finito sul tuo saldo: amount − fee, nell'unità minima.
data.paid_minorTotale ricevuto finora su questa fattura, nell'unità minima. Il campo che conta su underpaid: lo stato dice che è arrivato meno, questo dice quanto meno.
data.assetLa moneta effettivamente arrivata. Non necessariamente quella che la fattura chiedeva.
data.asset_mismatchPresente, e pari a true, solo quando la moneta arrivata non è quella della fattura. Il denaro ti viene accreditato, ma la fattura resta non pagata e status non sarà mai paid.
data.invoice_assetArriva insieme a asset_mismatch: la moneta che la fattura chiede davvero.
data.statusLo stato attuale della fattura: pending, underpaid, paid, overpaid o expired. Confrontalo con ciò che ti aspettavi.
data.tx_hashLa transazione on-chain, per i tuoi archivi e per il supporto.

Header presenti in ogni consegna

http
X-Paysell-Event:      payment.credited
X-Paysell-Event-Id:   99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp:  1789000000
X-Paysell-Signature:  sha256=6f1c0e6a…
HeaderSignificato
X-Paysell-EventIl tipo di evento: payment.credited o payment.rejected.
X-Paysell-Event-IdUnico per ogni evento. È il valore su cui deduplicare.
X-Paysell-TimestampQuando abbiamo firmato, in secondi unix. Fa parte della stringa firmata.
X-Paysell-Signaturesha256= seguito dall'HMAC in esadecimale. Vedi sotto.

Verificare la firma

Ogni richiesta è firmata con il segreto webhook mostrato una sola volta alla creazione della chiave. La firma è HMAC-SHA256(secret, "{timestamp}.{raw_body}") — il timestamp preso da X-Paysell-Timestamp, un punto letterale, poi i byte del corpo. Verificala prima di agire: senza questo, chiunque scopra il tuo URL può consegnarti un ordine risultante pagato.

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

Firma i byte grezzi del corpo, esattamente come ricevuti. Se analizzi il JSON e lo riserializzi i byte cambiano — ordine delle chiavi, spaziatura — e la firma non corrisponderà. Confronta a tempo costante (hmac.compare_digest, crypto.timingSafeEqual): un semplice == torna più in fretta quando il primo byte è sbagliato, e quella differenza basta per indovinare una firma un byte alla volta.

La finestra del timestamp

Rifiuta tutto ciò il cui timestamp dista più di cinque minuti dal tuo orologio, in entrambe le direzioni. Il timestamp sta dentro la stringa firmata proprio perché non possa essere modificato senza rompere la firma; è la finestra a trasformare tutto questo in protezione. Senza di essa una richiesta catturata una volta resta valida per sempre e può essere riprodotta in qualsiasi momento — la firma da sola non scade mai. Tieni l'orologio del tuo server su NTP, altrimenti questo controllo comincerà a rifiutare consegne buone.

Duplicati

Lo stesso evento può arrivare più di una volta. Non è un bug: ritentiamo finché non rispondi 2xx, e una consegna riuscita la cui risposta non ci è mai arrivata viene inviata di nuovo. Registra X-Paysell-Event-Id (arriva anche come event_id nel corpo) e fa' in modo che il secondo arrivo non faccia nulla.

Tentativi ripetuti

Il primo tentativo parte appena il pagamento viene accreditato. Se fallisce — timeout, connessione rifiutata, errore TLS, un redirect o qualsiasi stato non 2xx — ritentiamo secondo una scaletta fissa:

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

Sette tentativi in tutto, distribuiti su circa 31 ore. I primi sono ravvicinati perché la causa abituale è un ricevente che si stava riavviando ed è già tornato su; gli ultimi sono radi perché martellare un server fermo da un giorno non aiuta nessuno.

Dopo l'ultimo tentativo la consegna viene marcata dropped e ci fermiamo da soli. Non è persa: la riga del pagamento nella tua area cliente mostra lo stato, il numero di tentativi e la classe dell'errore, con un pulsante Invia di nuovo che avvia una nuova serie di tutti e sette i tentativi. L'altra risorsa che hai è GET /api/merchant/v1/invoices/{invoice_id} — la fattura conosce sempre il proprio stato.

Che aspetto deve avere una URL di webhook

La URL viene controllata quando la salvi, e di nuovo prima di ogni singola consegna. Una URL che non passa il controllo riceve 422 e code: "webhook_url_rejected" al salvataggio, e marca la consegna come failed — senza tentativi ripetuti — se comincia a fallire più tardi. Le regole:

  • Solo `https://`, e porta 443. Un webhook trasporta i dettagli di un pagamento; in http in chiaro sono leggibili da chiunque stia sul percorso.
  • Un nome di dominio, non un indirizzo IP. Un certificato ti serve comunque, e i certificati non vengono emessi per IP nudi.
  • Niente `localhost`, e nessun nome .local, .internal, .corp, .lan o .test — i nostri server non raggiungono la tua rete, e un nome che risolve dentro la nostra è esattamente ciò che non dobbiamo chiamare.
  • Nessuna credenziale nella URL (https://user:pass@…). Se ti serve, metti un tuo token nel percorso o in un parametro di query.
  • Ogni indirizzo a cui il nome risolve deve essere pubblico — sia A sia AAAA. Gli intervalli privati, di loopback, link-local e CGNAT vengono rifiutati, e il controllo si ripete prima di ogni consegna, quindi puntare il record su 127.0.0.1 in un secondo momento non funziona.
  • Un redirect è un fallimento, non un salto. Non lo seguiamo: l'indirizzo che ci hai dato è stato controllato, quello nell'header Location no.
La verifica avviene due volte di proposito — una quando salvi la URL, così un refuso riceve subito una risposta invece di tradursi in una mancata consegna silenziosa, e una prima di ogni invio, perché il proprietario di un dominio può ripuntarlo su un indirizzo interno in qualsiasi momento. Se il tuo endpoint cambia posizione, aggiorna prima la chiave: una URL rifiutata non consegna nulla e non mette nulla in coda.

Rispondi rapidamente

Va bene qualsiasi 2xx, entro dieci secondi — è tutto il nostro timeout, connessione compresa. Rispondi per primo, fai il lavoro lento dopo; un endpoint che aspetta il proprio database prima di rispondere prima o poi verrà registrato come timeout e ritentato, e ti ritroverai a processare due volte lo stesso evento. Qualsiasi altra cosa — un 4xx, un 5xx, un redirect, un blocco — conta come tentativo fallito e rientra nella scaletta qui sopra.

La consegna, onestamente

Ciò che è garantito è il meccanismo di consegna: sette tentativi in circa 31 ore, un reinvio manuale dalla tua area cliente, e un endpoint fattura che conosce sempre lo stato reale. Costruisci il flusso in modo che un webhook che non arriva mai non ti costi nulla — leggi la fattura nella tua pagina di ringraziamento, oppure riconcilia le fatture aperte una volta all'ora. I webhook sono la via veloce, non l'unica via.

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.

Riferimento stati#

Ogni stato di fattura e pagamento, spiegato.

Fattura

StatoSignificatoCosa fare
pendingIn attesa di pagamento.Mantieni l'ordine aperto.
paidPagata per intero.Rilascia il prodotto.
overpaidÈ arrivato più di quanto richiesto. L'eccedenza ti viene accreditata per intero.Rilascia il prodotto; rimborsa la differenza se lo desideri.
underpaidÈ arrivato meno di quanto richiesto. La fattura resta aperta e mantiene il suo indirizzo: l'acquirente può integrare allo stesso posto, e paid_minor dice quanto è già dentro. Resta pagabile per tutto il resto della sua vita più un periodo di grazia di 24 ore dopo expires_at.Attendi l'integrazione, oppure accordati con il cliente. Non rilasciare il prodotto — la fattura non è pagata.
expiredLa finestra si è chiusa, periodo di grazia compreso. Può comunque contenere denaro: quanto è arrivato è rimasto sul tuo saldo, e paid_minor dice quanto.Offri una nuova fattura. Non accettare pagamenti al vecchio indirizzo: quando una fattura scade, l'indirizzo torna nel pool, e un trasferimento molto tardivo diventa un caso per il supporto invece di un accredito automatico. Controlla paid_minor prima di dire al cliente che non è arrivato nulla.
cancelledAnnullata da te. L'indirizzo torna nel pool.Nulla.

Pagamento

Visibile nella tua area cliente; utile per assistere un cliente durante il pagamento.

StatoSignificato
detectedVisto sulla catena, in attesa di conferme.
confirmedLa rete lo ha confermato. Segue l'accredito.
creditedSul tuo saldo. È il momento in cui scatta il webhook.
reviewTrattenuto per un controllo aggiuntivo — ad esempio, monete arrivate a un indirizzo senza fattura aperta.
rejectedNon accreditato. Il motivo viene registrato.

Quando un pagamento finisce in `review`

Alcuni depositi vengono trattenuti per un controllo aggiuntivo invece di essere accreditati subito: una somma insolitamente grande, monete arrivate a un indirizzo senza fattura aperta, oppure le due fonti blockchain che interroghiamo che non concordano su ciò che è successo. Niente va perso — il denaro attende una decisione e il webhook scatta non appena c'è, cosa che può richiedere minuti od ore. Tratta come normale, e non come un guasto, l'assenza di callback su un pagamento mostrato come review. Se è rilevante per un ordine, scrivi al supporto citando il tx_hash.

Importi#

Unità normali in uscita, unità minime in ritorno.

Invia gli importi nelle unità normali della moneta, come stringa"1.5" è uno e mezzo. Non un numero JSON e non l'unità minima.

AssetDecimaliTu inviiamount_minor nella risposta
TON9"1.5""1500000000"
USDT_TON6"1.5""1500000"

Una stringa e non un numero, perché i numeri JSON sono double IEEE-754 e una somma grande in nanoton smette di esservi rappresentabile esattamente. Più decimali di quanti ne abbia la moneta è un 422, mai un arrotondamento silenzioso del tuo denaro. Nei webhook è il contrario:amount, fee e credited sono interi nell'unità minima, perché quel lato lo legge il codice, non una persona.

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

Limiti#

Minimi, massimi e limiti di frequenza.

LimiteValoreIn caso di violazione
Fattura minima0.1 TON · 3 USDT422
Fattura massima7000 TON · 10000 USDT422
Fatture all'ora, per negozio60429
Fatture aperte contemporaneamente20, in crescita a ogni fattura pagata, fino a 200429
Durata della fatturada 1 minuto a 24 ore (default 2 ore)422
Richieste API per chiave120 al minuto429 + Retry-After

Il minimo non è burocrazia. La nostra commissione è percentuale, ma incassare un pagamento ha un costo fisso: spostare USDT da un indirizzo di ricezione significa prima rifornirlo di gas, a nostre spese. Sotto pochi dollari la commissione non copre la gestione, e accettare un pagamento simile significherebbe accreditarti denaro il cui spostamento è antieconomico.

Il massimo non riguarda i negozi grandi: è una trappola per l'errore di unità. Invia "5000000" dove intendevi "5" e altrimenti otterresti una fattura da cinque milioni di dollari: l'acquirente vede una cifra assurda e se ne va. Un ordine vero non tocca mai questo tetto; un errore sempre. Entrambi i tetti sono impostazioni (invoice_max_ton, invoice_max_usdt) e possono essere alzati per il tuo negozio — basta chiedere.

Il tetto orario e quello sulle fatture aperte proteggono entrambi il pool di indirizzi. Ogni fattura aperta occupa un indirizzo di ricezione, e un loop fuori controllo su un sito prosciugherebbe altrimenti il pool per tutti. Un negozio nuovo può tenere 20 fatture aperte insieme; la disponibilità cresce di uno per ogni fattura che ha davvero incassato, fino a un tetto di 200. underpaid conta come aperta — sta ancora occupando il suo indirizzo, in attesa del resto. Annullare una fattura abbandonata restituisce subito il suo indirizzo. I tentativi ripetuti con lo stesso idempotency_key non pesano sul tetto orario.

Il limite di richieste è 120 al minuto per chiave API — due chiamate al secondo, ben oltre qualsiasi flusso d'ordini reale. Un 429 porta un header Retry-After in secondi: aspetta quel tempo invece di ritentare in un loop stretto, che non fa altro che spostare la finestra più avanti.

Errori#

I codici di stato che vedrai davvero.

Gli errori tornano in JSON, in due forme. Tutto ciò che decidiamo noi o il core di elaborazione mette una coppia {code, message} sotto detail. Un corpo di richiesta che non passa la validazione ci mette invece una lista di errori di campo. Verifica quale delle due hai ricevuto prima di leggere detail.code — e ramifica su `code`, mai su `message`: la formulazione può cambiare in qualsiasi momento, il codice no.

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
    }
  ]
}
StatoQuandoCosa fare
401Chiave mancante, errata o revocata.Controlla l'header. Riemetti la chiave se è stata revocata.
404Fattura inesistente, oppure appartenente a un altro negozio.Controlla l'id. I due casi rispondono allo stesso modo di proposito, così un id non può essere sondato.
409La fattura è in uno stato che lo vieta.Leggi prima il suo stato attuale.
422La richiesta è malformata, oppure l'importo è fuori dai limiti della fattura.Il messaggio indica sia il valore inviato sia il limite.
429Troppe fatture in quest'ora, troppe aperte insieme, oppure troppe richieste.Aspetta il Retry-After, poi ritenta.
502Non siamo riusciti a raggiungere il core di elaborazione.Ritenta con la stessa idempotency key.

Codici

La forma decisa da noi è {"detail": {"code": …, "message": …}}. Questi sono i codici restituiti dall'API merchant.

CodiceStatoSignificato
invalid_api_key401La chiave è mancante, malformata, sconosciuta o revocata. Tutti e quattro i casi rispondono allo stesso modo, così una chiave non può essere sondata.
not_found404Oggetto inesistente, oppure appartenente a un altro negozio.
invalid_input422La richiesta non ha passato la validazione nel core — un importo sbagliato, troppi decimali, un importo fuori dai limiti della fattura.
conflict409L'azione contraddice lo stato attuale, per esempio annullare una fattura che non è più aperta.
too_many_requests429Un limite di frequenza: fatture all'ora, fatture aperte o richieste al minuto. Retry-After dice quanto aspettare.
cbc_unreachable502Non siamo riusciti a raggiungere il core di elaborazione. Riprova con lo stesso idempotency_key.
webhook_url_rejected422Solo al salvataggio di una chiave: la URL del webhook non ha passato i controlli qui sopra. detail.reason indica quale regola — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials e così via.

Un 502 non significa che la fattura non sia stata creata — la richiesta potrebbe essere andata a buon fine con la risposta persa nel percorso di ritorno. Riprova con la stessa idempotency_key e otterrai o la fattura esistente o una nuova, mai due.

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.

Rimborsi#

Come rimborsare un cliente.

I rimborsi passano dal supporto, non da una chiamata API. Un rimborso è un nuovo trasferimento verso un indirizzo fornito da una persona, e un processore di pagamenti che rimanda indietro denaro automaticamente su chiamata API è un processore che si può indurre a mandare denaro all'indirizzo di un attaccante. Perciò è manuale di proposito.

Per rimborsare un acquirente, apri un ticket di supporto dalla tua area cliente con l'invoice_id o il tx_hash, l'importo e l'indirizzo di destinazione. Un operatore verifica il pagamento, sposta il denaro fuori dal tuo saldo e risponde nello stesso ticket. Aspettati che richieda una giornata lavorativa, non un minuto.

Due conseguenze su cui vale la pena progettare. L'eccedenza ti viene accreditata per intero — noi non ne tratteniamo nulla — quindi restituire la differenza a un acquirente che ha inviato troppo è una tua scelta e segue la stessa via. E una fattura underpaid non è un caso di rimborso finché è ancora aperta: il denaro è sul tuo saldo, l'indirizzo è ancora sorvegliato e l'acquirente può semplicemente integrare. Solo dopo il periodo di grazia, quando la fattura passa a expired con del denaro sopra, c'è una decisione da prendere.

Test#

Come testare l'integrazione prima del lancio.

Qui le chiavi sono reali: ogni chiave emessa è una chiave sk_live_ contro il core di produzione e la mainnet TON. Non esiste un ambiente di test separato, e c'è un lato positivo: percorri esattamente la strada che faranno i tuoi ordini veri.

Quindi fai le prove come le faresti con qualsiasi cosa tocchi denaro vero: su importi piccoli. Crea una fattura del minimo (0.1 TON o 3 USDT), pagala dal tuo wallet e osserva tutto il percorso — la pagina di pagamento, il webhook, la verifica della firma, il tuo ordine che passa a pagato. La commissione si applica, e le monete si muovono davvero.

Le parti che puoi esercitare senza spendere nulla: creare e leggere una fattura, annullarla, il 422 su un importo malformato, il 401 su una chiave sbagliata e la tua verifica della firma — firma un corpo di esempio con il tuo segreto e passalo al tuo stesso handler. Ciò che richiede davvero un pagamento reale è solo l'ultimo passo: un vero webhook payment.credited.

Pianifica l'integrazione in modo che non dipenda da una sandbox o da un pagamento simulato: il percorso reale si verifica più in fretta — e più onestamente.

Tratta il tuo primo ordine reale come il vero test: scegli un importo piccolo, tieni la fattura aperta nella dashboard e controlla la riga del pagamento e lo stato del webhook prima di indirizzarci clienti veri.

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.

Checklist per il lancio#

Dieci cose da verificare prima del lancio.

  • La chiave è solo lato server, mai nel JavaScript del browser.
  • La firma del webhook è verificata contro "{timestamp}.{raw_body}", a tempo costante.
  • Le consegne più vecchie di cinque minuti vengono rifiutate, e l'orologio del server è su NTP.
  • Un X-Paysell-Event-Id ripetuto non fa nulla la seconda volta.
  • Il webhook risponde 2xx entro dieci secondi; il lavoro lento avviene dopo.
  • La URL del webhook è un dominio https:// sulla porta 443, senza redirect davanti.
  • Un webhook mancato è sopravvivibile: l'endpoint fattura viene letto nella pagina di ringraziamento o in una passata di riconciliazione.
  • idempotency_key è generato una volta per ordine e riutilizzato nei tentativi successivi.
  • Gli importi escono come stringhe in unità normali; i numeri del webhook si leggono in unità minime.
  • L'indirizzo è mostrato esattamente come restituito, senza modifiche.
  • overpaid e underpaid sono gestiti, non solo paid; expired può comunque portare paid_minor.
  • La merce si consegna su status: paid o overpaid, mai al semplice arrivo della chiamata.
  • Il 429 si gestisce aspettando il Retry-After, non ritentando subito.
  • I saldi vengono letti da noi, non tracciati separatamente come verità.

Qualcosa non è chiaro?

Se questa pagina non ha risposto alla tua domanda, si tratta di una lacuna nella documentazione ed è utile segnalarcelo. Scrivici dalla tua area cliente e correggeremo la pagina, non solo la risposta.