Paysell

Acceptez les paiements en crypto

Paysell règle en TON et USDT sur le réseau TON. Vous créez une facture, nous vous donnons un lien, et vous recevez un callback signé dès que l'argent est confirmé sur la chaîne et crédité sur votre solde.

Aperçu#

Ce que fait Paysell, et ce qu'il ne fait pas.

Paysell est un prestataire de paiement, pas un portefeuille. Vous ne manipulez jamais de clés privées, ne surveillez pas la blockchain et ne décidez pas quand une transaction est définitive — c'est notre travail.

Chaque facture reçoit sa propre adresse de réception. Quand un acheteur la paie, nous attendons que le réseau confirme le transfert, déduisons notre commission et créditons le reste sur votre solde. Vous retirez vers l'adresse de votre choix.

Les soldes vivent chez nous et constituent la seule source de vérité. Affichez-les, mais ne gardez jamais une seconde copie comme faisant autorité — deux compteurs finissent toujours par diverger, et alors plus personne ne sait lequel est le bon.

Comment se déroule un paiement#

Six étapes, la plupart chez nous.

Six étapes, la plupart chez nous :

  1. 1

    Votre client clique sur payer

    Votre serveur appelle notre API avec le montant et votre propre référence de commande.

  2. 2

    Nous fournissons une adresse

    Une adresse de réception fraîche est tirée d'un pool pré-généré et liée à cette facture. Une adresse appartient exactement à une facture ouverte, ce qui permet de rattacher un paiement à celle-ci.

  3. 3

    Le client envoie les pièces

    Il scanne le QR code ou copie l'adresse. Envoyez-le vers la payment_url que nous renvoyons et la page est déjà prise en charge pour vous — montant, adresse, QR, compte à rebours, statut en direct.

  4. 4

    Nous repérons le transfert

    Deux sources indépendantes de données blockchain sont interrogées, et leurs réponses comparées. En cas de désaccord, nous nous arrêtons plutôt que de choisir la réponse la plus commode.

  5. 5

    Nous attendons la finalité

    Inclusion dans la masterchain plus trois blocs par-dessus. Environ quinze secondes — un paiement qui paraît réglé puis disparaît serait votre perte, donc nous ne prenons pas ce risque.

  6. 6

    Crédité, et vous êtes prévenu

    La commission est déduite, le reste arrive sur votre solde, et un webhook signé part vers votre serveur avec votre order_id.

Du paiement au callback : environ une minute — quinze secondes de confirmations réseau, le reste étant notre balayage des adresses surveillées.

Où va l'argent#

La commission, et sur quoi elle est calculée.

La commission est de 0,2 %, fixée pour votre boutique au moment de son enregistrement. Si le taux standard change ensuite, le vôtre ne change pas — il est inscrit dans chaque facture comme un nombre, pas comme une référence à un paramètre.

La commission est prélevée sur ce qui arrive réellement, pas sur ce que la facture demandait. Facturez 5 USDT et recevez 20, la commission est calculée sur 20. Sous-payé ? Elle est calculée sur ce qui est arrivé.

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

Le trop-perçu est crédité intégralement — nous ne gardons pas la différence. Le sous-paiement laisse la facture ouverte pour que l'acheteur puisse compléter vers la même adresse.

Sortir des pièces d'une adresse de réception coûte du gas réseau, et c'est nous qui le payons — cette partie ne touche jamais votre solde. Un retrait vers votre propre adresse est autre chose : il porte sa propre commission, déduite du montant que vous demandez, et les chiffres exacts figurent dans la Grille tarifaire.

Démarrage rapide#

Cinq minutes jusqu'à votre première facture.

Cinq étapes. Deux sont des clics dans votre espace client, une est une seule requête depuis votre serveur, et les deux dernières se font toutes seules.

  1. 1

    Créez une boutique

    Dans votre espace client. Elle commence à accepter les paiements immédiatement — sans attendre de validation. La vérification se déroule discrètement en arrière-plan et ne bloque que les retraits, pas les paiements entrants.

  2. 2

    Générez une clé API

    Votre boutique → Clés API → Nouvelle clé. La clé et le secret du webhook ne sont affichés qu'une seule fois, jamais ensuite. Conservez-les comme un mot de passe de base de données, et ne les envoyez jamais dans un navigateur.

  3. 3

    Créez une facture

    Une requête depuis votre serveur, un lien en retour. Les quatre extraits ci-dessous envoient tous exactement la même chose.

  4. 4

    Envoyez l'acheteur vers payment_url

    C'est tout le processus de paiement — montant, adresse, QR code, compte à rebours, statut en direct — et il n'y a rien à construire. Voir Page de paiement pour ce que l'acheteur voit réellement.

  5. 5

    Attendez le webhook

    Une fois l'argent confirmé on-chain et crédité, nous envoyons en POST un événement payment.credited signé vers votre serveur. Vérifiez la signature, puis marquez la commande comme payée — mais seulement si data.status vaut paid ou overpaid. Voir Webhooks.

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

Redirigez l'acheteur vers la payment_url de la réponse. C'est fait — le reste arrivera par webhook.

What to do next

Authentification#

Votre clé API, et comment elle est utilisée.

Chaque requête porte votre clé dans l'en-tête Authorization :

http
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA

Toute clé émise ici commence par sk_live_. Le préfixe sk_test_ n'existe que sur un déploiement pointé vers le réseau de test, et aucun déploiement de ce type n'est proposé — voir Tests. Nous stockons un hash à sens unique, pas la clé elle-même, donc personne, nous y compris, ne peut vous la remontrer. Perdue ? Générez-en une nouvelle et révoquez l'ancienne.

La boutique est déduite de la clé, c'est pourquoi aucune requête ne prend d'id de boutique. Une clé ne peut agir que sur sa propre boutique.

Le chemin porte une version : /api/merchant/v1/…. À l'intérieur d'une version, nous ne faisons qu'ajouter des champs — rien n'est renommé ni ne change de sens en silence. Un changement qui casserait votre code reçoit un nouveau préfixe, /v2, et /v1 continue de fonctionner pendant une durée annoncée.

Cette clé crée des factures en votre nom. Gardez-la côté serveur. Tout ce qui se trouve dans du JavaScript navigateur est public, aussi bien caché soit-il.

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.

Créer une facture#

POST /api/merchant/v1/invoices

POST/api/merchant/v1/invoices

Corps de la requête

ChampTypeObligatoireDescription
assetstringouiTON ou USDT_TON.
amountstringouiUnités normales de la monnaie, sous forme de chaîne : "5", c'est 5 USDT. Pas plus de décimales que n'en a la monnaie. Voir Montants.
order_idstringnonVotre propre référence, jusqu'à 200 caractères. Elle revient dans chaque webhook — c'est ainsi que vous rattachez un paiement à une commande.
descriptionstringnonJusqu'à 1000 caractères. Affiché à l'acheteur sur la page de paiement.
ttl_minutesnumbernonCombien de temps la facture reste payable, en minutes. 1–1440 ; omettez-le et la valeur par défaut s'applique — 2 heures aujourd'hui.
idempotency_keystringnonJusqu'à 200 caractères. Envoyez la même valeur lors d'une nouvelle tentative et vous récupérez la même facture au lieu d'une seconde. Un champ du corps, pas l'en-tête Idempotency-Key — cet en-tête n'est pas lu ici.

Réponse · 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"
}

Utiliser ces champs dans votre commande

ChampQue faire avec
invoice_idStockez-le avec votre commande. C'est ce qui identifie le paiement partout ailleurs.
payment_urlRedirigez l'acheteur ici. Il n'y a rien d'autre à construire.
addressSeulement si vous construisez votre propre paiement. Affichez-la exactement telle que reçue — voir l'avertissement ci-dessous.
amountLe montant en unités normales, exactement tel que vous l'avez envoyé. Affichez celui-ci.
amount_minorLe même montant en entier, en unité minimale. Calculez avec celui-ci.
expires_atAffichez un compte à rebours. Une fois passé, l'adresse n'est plus surveillée pour cette facture.
statusToujours pending ici. Les vrais changements arrivent par webhook.
Si vous construisez votre propre page, affichez l'adresse exactement telle que renvoyée. Elle est sous forme non-bounceable (UQ… sur mainnet, 0Q… sur testnet). La convertir, l'embellir ou la remplacer par un autre encodage de la même adresse fera revenir à l'expéditeur les pièces envoyées vers un portefeuille pas encore déployé.

Lire une facture#

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

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

Même structure que ci-dessus, avec status, paid et paid_minor qui reflètent l'état présent : paid est ce qui est arrivé, en unités normales, et paid_minor la même chose sous forme d'entier en unité minimale. Utile en secours si un webhook a été manqué, ou sur une page de remerciement.

Interrogez-la au plus toutes les quelques secondes, et considérez les webhooks comme le canal principal. Les factures appartenant à une autre boutique répondent 404 — pas 403, pour qu'un id ne puisse pas être sondé quant à son existence.

Annuler une facture#

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

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

Ferme une facture encore ouverte — pending ou underpaid — et libère son adresse. Utilisez-la quand le client abandonne le paiement : les adresses sont une ressource limitée, et les rendre garde le pool en bonne santé.

Une facture qui n'est plus ouverte répond 409. Annuler une facture underpaid ne rend de pièces à personne : l'argent déjà crédité reste sur votre solde, et tout ce qui se ferme, c'est l'acceptation d'un complément.

Webhooks#

Ce qui arrive, et comment le vérifier.

Indiquez une URL de webhook à la création de la clé. Nous y faisons un POST quand un paiement est crédité — et quand un dépôt retenu pour une vérification supplémentaire est refusé. Chaque livraison est signée, et nous réessayons pendant environ un jour et demi jusqu'à ce que vous répondiez 2xx. Livrez la marchandise sur status: paid ou overpaid, pas sur la simple arrivée de l'appel.

Événements

ÉvénementQuandCe que contient le corps
payment.creditedLe transfert est confirmé sur la chaîne, notre commission est prélevée et le reste est sur votre solde.Les champs listés ci-dessous.
payment.rejectedUn dépôt retenu pour une vérification supplémentaire (voir Référence des statuts) a été refusé. L'argent n'atteindra pas votre solde.invoice_id, order_id, asset, amount, tx_hash et reason. Ne livrez pas la marchandise ; si la facture était déjà paid grâce à un transfert antérieur, cet événement concerne le dépôt supplémentaire, pas ce paiement.

Ce qui arrive

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

Correspondance des champs

ChampSignification
event_idUnique par événement ; présent aussi dans l'en-tête X-Paysell-Event-Id. Stockez-le et ignorez les répétitions — voir ci-dessous.
data.order_idVotre référence. Recherchez votre commande grâce à elle.
data.amountCe que l'acheteur a envoyé dans ce transfert, en unité minimale — contrairement à l'API, qui attend des unités normales.
data.feeCe que nous avons prélevé, en unité minimale.
data.creditedCe qui a atterri sur votre solde : amount − fee, en unité minimale.
data.paid_minorTotal reçu sur cette facture jusqu'à présent, en unité minimale. Le champ qui compte en cas d'underpaid : le statut dit qu'il est arrivé moins, celui-ci dit combien de moins.
data.assetLa monnaie réellement reçue. Pas forcément celle que la facture réclamait.
data.asset_mismatchPrésent, et à true, uniquement quand la monnaie reçue n'est pas celle de la facture. L'argent vous est crédité, mais la facture reste impayée et status ne vaudra jamais paid.
data.invoice_assetAccompagne asset_mismatch : la monnaie que la facture réclame réellement.
data.statusLe statut actuel de la facture : pending, underpaid, paid, overpaid ou expired. Comparez à ce que vous attendiez.
data.tx_hashLa transaction on-chain, pour vos registres et le support.

En-têtes présents sur chaque livraison

http
X-Paysell-Event:      payment.credited
X-Paysell-Event-Id:   99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp:  1789000000
X-Paysell-Signature:  sha256=6f1c0e6a…
En-têteSignification
X-Paysell-EventLe type d'événement : payment.credited ou payment.rejected.
X-Paysell-Event-IdUnique par événement. C'est la valeur sur laquelle dédupliquer.
X-Paysell-TimestampL'instant de notre signature, en secondes unix. Il fait partie de la chaîne signée.
X-Paysell-Signaturesha256= suivi du HMAC en hexadécimal. Voir ci-dessous.

Vérifier la signature

Chaque requête est signée avec le secret du webhook affiché une seule fois lors de la création de la clé. La signature vaut HMAC-SHA256(secret, "{timestamp}.{raw_body}") — l'horodatage issu de X-Paysell-Timestamp, un point littéral, puis les octets du corps. Vérifiez-la avant d'agir : sans cela, quiconque découvre votre URL peut vous soumettre une commande payée.

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

Signez les octets bruts du corps, exactement tels que reçus. Si vous analysez le JSON puis le re-sérialisez, les octets changent — ordre des clés, espaces — et la signature ne correspondra plus. Comparez en temps constant (hmac.compare_digest, crypto.timingSafeEqual) : un simple == revient plus vite quand le premier octet est faux, et cet écart suffit à deviner une signature octet par octet.

La fenêtre d'horodatage

Rejetez tout ce dont l'horodatage s'écarte de plus de cinq minutes de votre propre horloge, dans un sens comme dans l'autre. L'horodatage se trouve dans la chaîne signée précisément pour qu'il ne puisse pas être modifié sans casser la signature ; c'est la fenêtre qui transforme cela en protection. Sans elle, une requête capturée une fois reste valable pour toujours et peut être rejouée à n'importe quel moment — la signature seule n'expire jamais. Gardez l'horloge de votre serveur synchronisée par NTP, sinon ce contrôle se mettra à rejeter des livraisons légitimes.

Doublons

Le même événement peut arriver plus d'une fois. Ce n'est pas un bug : nous réessayons jusqu'à ce que vous répondiez 2xx, et une livraison réussie dont la réponse ne nous est jamais parvenue est renvoyée. Enregistrez X-Paysell-Event-Id (il arrive aussi comme event_id dans le corps) et faites en sorte que la seconde arrivée ne fasse rien.

Nouvelles tentatives

La première tentative part dès que le paiement est crédité. Si elle échoue — délai dépassé, connexion refusée, erreur TLS, une redirection, ou tout statut non-2xx — nous réessayons selon un calendrier fixe :

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

Sept tentatives au total, étalées sur environ 31 heures. Les premières sont rapprochées parce que la cause habituelle est un destinataire qui redémarrait et qui est déjà revenu ; les dernières sont espacées parce que marteler un serveur hors service depuis un jour n'aide personne.

Après la dernière tentative, la livraison est marquée dropped et nous arrêtons de nous-mêmes. Rien n'est perdu : la ligne de paiement dans votre espace client affiche l'état, le nombre de tentatives et la classe d'erreur, avec un bouton Renvoyer qui relance une série complète de sept tentatives. Votre autre recours est GET /api/merchant/v1/invoices/{invoice_id} — la facture connaît toujours son propre statut.

À quoi doit ressembler une URL de webhook

L'URL est vérifiée à l'enregistrement, puis de nouveau avant chaque livraison. Une URL qui échoue au contrôle reçoit un 422 avec code: "webhook_url_rejected" au moment de l'enregistrement, et marque la livraison failed — sans nouvelle tentative — si elle se met à échouer plus tard. Les règles :

  • `https://` uniquement, et port 443. Un webhook transporte des détails de paiement ; en http simple, ils sont lisibles par quiconque se trouve sur le trajet.
  • Un nom de domaine, pas une adresse IP. Il vous faut de toute façon un certificat, et les certificats ne sont pas délivrés pour de simples IP.
  • Pas de `localhost`, ni de nom en .local, .internal, .corp, .lan ou .test — nos serveurs ne peuvent pas atteindre votre réseau, et un nom qui se résout à l'intérieur du nôtre est exactement ce que nous ne devons pas appeler.
  • Pas d'identifiants dans l'URL (https://user:pass@…). Mettez votre propre jeton dans le chemin ou dans un paramètre de requête si vous en avez besoin.
  • Chaque adresse vers laquelle le nom se résout doit être publique — A comme AAAA. Les plages privées, de bouclage, link-local et CGNAT sont refusées, et le contrôle est répété avant chaque livraison : pointer l'enregistrement vers 127.0.0.1 plus tard ne fonctionne donc pas non plus.
  • Une redirection est un échec, pas une étape. Nous ne les suivons pas : l'adresse que vous nous avez donnée a été vérifiée, celle d'un en-tête Location ne l'a pas été.
La vérification a lieu deux fois à dessein — une fois à l'enregistrement de l'URL, pour qu'une faute de frappe reçoive une réponse immédiate plutôt qu'une non-livraison silencieuse, et une fois avant chaque envoi, parce que le propriétaire d'un domaine peut le repointer vers une adresse interne à tout moment. Si votre endpoint déménage, mettez d'abord la clé à jour : une URL rejetée ne livre rien et ne met rien en file d'attente.

Répondez vite

N'importe quel 2xx convient, en dix secondes — c'est notre délai total, connexion comprise. Répondez d'abord, faites le travail lent ensuite ; un endpoint qui attend sa propre base de données avant de répondre finira par être enregistré comme un délai dépassé et réessayé, et vous traiterez deux fois le même événement. Tout le reste — un 4xx, un 5xx, une redirection, un blocage — compte comme une tentative échouée et repart dans le calendrier ci-dessus.

La livraison, franchement

Ce qui est garanti, c'est le mécanisme de livraison : sept tentatives sur environ 31 heures, un renvoi manuel depuis votre espace client, et un endpoint de facture qui connaît toujours le vrai statut. Construisez le flux de sorte qu'un webhook qui n'arrive jamais ne vous coûte rien — lisez la facture sur votre page de remerciement, ou rapprochez les factures ouvertes une fois par heure. Les webhooks sont la voie rapide, pas la seule voie.

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.

Référence des statuts#

Tous les statuts de facture et de paiement, expliqués.

Facture

StatutSignificationQue faire
pendingEn attente de paiement.Gardez la commande ouverte.
paidPayée intégralement.Livrez le produit.
overpaidIl est arrivé plus que demandé. Le surplus vous est crédité intégralement.Livrez le produit ; remboursez la différence si vous le souhaitez.
underpaidIl est arrivé moins que demandé. La facture reste ouverte et conserve son adresse : l'acheteur peut la compléter au même endroit, et paid_minor indique ce qui est déjà arrivé. Elle reste payable pendant le reste de sa durée de vie, plus un délai de grâce de 24 heures après expires_at.Attendez le complément, ou arrangez-vous avec le client. Ne livrez pas le produit — la facture n'est pas payée.
expiredLe délai s'est écoulé, délai de grâce compris. Peut malgré tout porter de l'argent : ce qui est arrivé est resté sur votre solde, et paid_minor indique combien.Proposez une nouvelle facture. N'acceptez pas de paiement sur l'ancienne adresse : dès qu'une facture expire, l'adresse retourne au pool, et un virement très tardif relève du support plutôt que d'un crédit automatique. Vérifiez paid_minor avant de dire au client que rien n'a été reçu.
cancelledAnnulée par vous. L'adresse est rendue au pool.Rien.

Paiement

Visible dans votre espace client ; utile pour assister un client en cours de paiement.

StatutSignification
detectedRepéré sur la chaîne, en attente de confirmations.
confirmedLe réseau l'a confirmé. Crédit à suivre.
creditedSur votre solde. C'est le moment où le webhook se déclenche.
reviewRetenu pour une vérification supplémentaire — par exemple des pièces arrivant sur une adresse sans facture ouverte.
rejectedNon crédité. La raison est enregistrée.

Quand un paiement passe en `review`

Certains dépôts sont retenus pour une vérification supplémentaire au lieu d'être crédités immédiatement : une somme inhabituellement élevée, des pièces arrivant sur une adresse sans facture ouverte, ou les deux sources blockchain que nous interrogeons qui ne s'accordent pas sur ce qui s'est passé. Rien n'est perdu — l'argent attend une décision et le webhook se déclenche dès qu'elle tombe, ce qui peut prendre des minutes ou des heures. Considérez l'absence de callback sur un paiement affiché en review comme normale, et non comme une panne. Si cela bloque une commande, contactez le support en citant le tx_hash.

Montants#

Unités normales à l'envoi, unités minimales au retour.

Envoyez les montants dans les unités normales de la monnaie, sous forme de chaîne"1.5", c'est un et demi. Ni un nombre JSON, ni l'unité minimale.

ActifDécimalesVous envoyezamount_minor dans la réponse
TON9"1.5""1500000000"
USDT_TON6"1.5""1500000"

Une chaîne plutôt qu'un nombre, parce que les nombres JSON sont des doubles IEEE-754 et qu'une grosse somme en nanotons cesse d'y être représentée exactement. Plus de décimales que n'en a la monnaie, c'est un 422, jamais un arrondi silencieux de votre argent. Les webhooks fonctionnent à l'inverse : amount, fee et credited y sont des entiers en unité minimale, parce que ce côté-là est lu par du code, pas par un humain.

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

Limites#

Minimums, maximums et limites de fréquence.

LimiteValeurEn cas de dépassement
Facture minimale0.1 TON · 3 USDT422
Facture maximale7000 TON · 10000 USDT422
Factures par heure, par boutique60429
Factures ouvertes simultanément20, en hausse à chaque facture payée, jusqu'à 200429
Durée de vie de la facture1 minute – 24 heures (2 heures par défaut)422
Requêtes API par clé120 par minute429 + Retry-After

Le minimum n'est pas de la bureaucratie. Notre commission est un pourcentage, mais encaisser un paiement coûte un montant fixe : sortir de l'USDT d'une adresse de réception implique de l'approvisionner en gas au préalable, de notre poche. En dessous de quelques dollars, la commission ne couvre pas le traitement, et accepter un tel paiement reviendrait à vous créditer de l'argent qu'il n'est pas rentable de déplacer.

Le maximum n'est pas là pour les gros marchands : c'est un piège pour l'erreur d'unités. Envoyez "5000000" là où vous vouliez "5" et, sans lui, vous obtiendriez une facture de cinq millions de dollars : l'acheteur voit un montant absurde et s'en va. Une vraie commande n'atteint jamais ce plafond ; une erreur, toujours. Les deux plafonds sont des réglages (invoice_max_ton, invoice_max_usdt) et peuvent être relevés pour votre boutique — demandez-nous.

Le plafond horaire et le plafond de factures ouvertes protègent tous deux le pool d'adresses. Chaque facture ouverte occupe une adresse de réception, et une boucle incontrôlée sur un site épuiserait sinon le pool pour tout le monde. Une nouvelle boutique peut garder 20 factures ouvertes à la fois ; ce quota augmente d'une unité pour chaque facture réellement encaissée, jusqu'à un plafond de 200. underpaid compte comme ouverte — elle occupe toujours son adresse, en attendant le reste. Annuler une facture abandonnée libère son adresse immédiatement. Les nouvelles tentatives avec la même idempotency_key ne comptent pas dans le plafond horaire.

La limite de requêtes est de 120 par minute et par clé API — deux appels par seconde, bien au-delà de tout flux de commandes réel. Un 429 porte un en-tête Retry-After en secondes : attendez ce délai plutôt que de réessayer en boucle serrée, ce qui ne fait que repousser la fenêtre.

Erreurs#

Les codes de statut que vous verrez vraiment.

Les erreurs reviennent en JSON, sous deux formes. Tout ce que nous ou le cœur de traitement décidons place un couple {code, message} sous detail. Un corps de requête qui échoue à la validation y place à la place une liste d'erreurs de champs. Vérifiez laquelle des deux vous avez reçue avant de lire detail.code — et branchez sur `code`, jamais sur `message` : la formulation peut changer à tout moment, le code non.

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
    }
  ]
}
StatutQuandQue faire
401Clé absente, incorrecte ou révoquée.Vérifiez l'en-tête. Régénérez la clé si elle a été révoquée.
404Cette facture n'existe pas, ou elle appartient à une autre boutique.Vérifiez l'id. Les deux cas répondent de la même façon à dessein, pour qu'un id ne puisse pas être sondé.
409La facture est dans un état qui l'interdit.Lisez d'abord son statut actuel.
422La requête est mal formée, ou le montant est hors des limites de la facture.Le message indique à la fois la valeur envoyée et la limite.
429Trop de factures cette heure-ci, trop de factures ouvertes à la fois, ou trop de requêtes.Attendez la fin du Retry-After, puis réessayez.
502Nous n'avons pas pu joindre le cœur de traitement.Réessayez avec la même clé d'idempotence.

Codes

La forme que nous décidons est {"detail": {"code": …, "message": …}}. Voici les codes que renvoie l'API marchand.

CodeStatutSignification
invalid_api_key401La clé est absente, mal formée, inconnue ou révoquée. Les quatre cas reçoivent la même réponse, de sorte qu'une clé ne peut pas être sondée.
not_found404Cet objet n'existe pas, ou il appartient à une autre boutique.
invalid_input422La requête n'a pas passé la validation dans le cœur — un montant incorrect, trop de décimales, un montant hors des limites de la facture.
conflict409L'action contredit l'état actuel, par exemple annuler une facture qui n'est plus ouverte.
too_many_requests429Une limite de fréquence : factures par heure, factures ouvertes, ou requêtes par minute. Retry-After indique combien de temps attendre.
cbc_unreachable502Nous n'avons pas pu joindre le cœur de traitement. Réessayez avec la même idempotency_key.
webhook_url_rejected422Uniquement à l'enregistrement d'une clé : l'URL de webhook a échoué aux contrôles ci-dessus. detail.reason nomme la règle en cause — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials, etc.

Un 502 ne signifie pas que la facture n'a pas été créée — la requête a pu aboutir avec la réponse perdue au retour. Réessayez avec la même idempotency_key et vous obtiendrez soit la facture existante, soit une nouvelle, jamais deux.

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.

Remboursements#

Comment rembourser un client.

Les remboursements passent par le support, pas par un appel d'API. Un remboursement est un nouveau transfert vers une adresse fournie par une personne, et un processeur de paiement qui renvoie de l'argent automatiquement sur un appel d'API est un processeur qu'on peut amener à envoyer de l'argent vers l'adresse d'un attaquant. C'est donc manuel à dessein.

Pour rembourser un acheteur, ouvrez un ticket de support depuis votre espace client avec l'invoice_id ou le tx_hash, le montant, et l'adresse de destination. Un opérateur vérifie le paiement, sort l'argent de votre solde et répond dans le même ticket. Comptez un jour ouvré, pas une minute.

Deux conséquences à intégrer dans votre conception. Le trop-perçu vous est crédité intégralement — nous n'en gardons rien — donc rendre la différence à un acheteur qui a trop envoyé relève de votre décision et suit le même chemin. Et une facture sous-payée n'est pas un cas de remboursement tant qu'elle est encore ouverte : l'argent est sur votre solde, l'adresse est toujours surveillée, et l'acheteur peut simplement la compléter. Ce n'est qu'après le délai de grâce, quand la facture passe en expired avec de l'argent dessus, qu'il y a une décision à prendre.

Tests#

Comment tester votre intégration avant le lancement.

Les clés sont ici des clés de production : toute clé émise est une clé sk_live_ contre le cœur de production et le mainnet TON. Il n'existe pas d'environnement de test séparé, et cela a un avantage : vous parcourez exactement le chemin que prendront vos vraies commandes.

Testez donc comme vous testeriez tout ce qui touche à de l'argent réel : sur de petits montants. Créez une facture au minimum (0.1 TON ou 3 USDT), payez-la depuis votre propre portefeuille, et observez tout le parcours — la page de paiement, le webhook, la vérification de signature, votre commande qui bascule en payée. La commission s'applique, et les pièces bougent vraiment.

Ce que vous pouvez exercer sans rien dépenser : créer et lire une facture, en annuler une, le 422 sur un montant mal formé, le 401 sur une mauvaise clé, et votre propre vérification de signature — signez un corps d'exemple avec votre secret et donnez-le à votre propre gestionnaire. Ce qui exige vraiment un paiement réel, c'est seulement la dernière étape : un véritable webhook payment.credited.

Concevez l'intégration pour qu'elle ne dépende ni d'une sandbox ni d'un paiement simulé : la voie réelle se vérifie plus vite — et plus fidèlement.

Considérez votre première commande réelle comme le vrai test : choisissez un petit montant, gardez la facture ouverte dans le tableau de bord, et vérifiez la ligne de paiement et l'état du webhook avant d'y diriger de vrais clients.

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.

Check-list avant mise en production#

Dix points à vérifier avant le lancement.

  • La clé reste côté serveur uniquement, jamais dans du JavaScript navigateur.
  • La signature du webhook est vérifiée par rapport à "{timestamp}.{raw_body}", en temps constant.
  • Les livraisons datant de plus de cinq minutes sont rejetées, et l'horloge du serveur est synchronisée par NTP.
  • Un X-Paysell-Event-Id répété ne fait rien la seconde fois.
  • Le webhook répond 2xx en dix secondes ; le travail lent se fait ensuite.
  • L'URL du webhook est un domaine https:// sur le port 443, sans redirection devant.
  • Un webhook manqué est sans conséquence : l'endpoint de facture est lu sur la page de remerciement ou lors d'un balayage de réconciliation.
  • idempotency_key est généré une fois par commande et réutilisé lors des nouvelles tentatives.
  • Les montants partent en chaînes d'unités normales ; les chiffres du webhook se lisent en unités minimales.
  • L'adresse est affichée exactement telle que renvoyée, sans modification.
  • overpaid et underpaid sont gérés, pas seulement paid ; expired peut malgré tout porter un paid_minor.
  • La marchandise est livrée sur status: paid ou overpaid, jamais sur la simple arrivée de l'appel.
  • Un 429 est géré en attendant la fin du Retry-After, pas en réessayant immédiatement.
  • Les soldes sont lus chez nous, pas suivis séparément comme vérité.

Quelque chose n'est pas clair ?

Si cette page n'a pas répondu à votre question, c'est un trou dans la documentation qui mérite d'être signalé. Écrivez-nous depuis votre espace client et nous corrigerons la page, pas seulement la réponse.