Paysell

Accepteer crypto-betalingen

Paysell verrekent TON en USDT op het TON-netwerk. Je maakt een factuur aan, wij geven je een link, en je krijgt een ondertekende callback zodra het geld on-chain is bevestigd en op je saldo is bijgeschreven.

Overzicht#

Wat Paysell doet, en wat niet.

Paysell is een betalingsverwerker, geen wallet. Je hebt nooit te maken met privésleutels, houdt de blockchain niet in de gaten en bepaalt niet wanneer een transactie definitief is — dat nemen wij voor onze rekening.

Elke factuur krijgt zijn eigen ontvangstadres. Wanneer een koper betaalt, wachten we tot het netwerk de overdracht bevestigt, trekken we onze vergoeding af en schrijven we de rest bij op je saldo. Je kunt naar elk gewenst adres opnemen.

Saldi worden bij ons bewaard en zijn de enige bron van waarheid. Toon ze gerust, maar houd nooit een tweede kopie bij als leidend — twee tellers lopen uiteindelijk altijd uit elkaar, en dan weet niemand meer welke klopt.

Hoe een betaling werkt#

Zes stappen, de meeste voor onze rekening.

Zes stappen, de meeste voor onze rekening:

  1. 1

    Je klant klikt op betalen

    Je server roept onze API aan met het bedrag en je eigen orderreferentie.

  2. 2

    Wij geven een adres uit

    Een vers ontvangstadres wordt uit een vooraf gegenereerde pool gehaald en aan deze factuur gekoppeld. Eén adres hoort bij precies één openstaande factuur — zo wordt een betaling eraan gekoppeld.

  3. 3

    De klant stuurt munten

    Ze scannen de QR-code of kopiëren het adres. Stuur ze naar de payment_url die wij teruggeven en de pagina wordt voor je afgehandeld — bedrag, adres, QR, aftellen, live status.

  4. 4

    Wij signaleren de overdracht

    Twee onafhankelijke bronnen van blockchaindata worden bevraagd en hun antwoorden vergeleken. Zijn ze het oneens, dan stoppen we — in plaats van het gunstigste antwoord te kiezen.

  5. 5

    Wij wachten op finaliteit

    Opname in de masterchain plus drie blokken erbovenop. Ongeveer vijftien seconden — een betaling die afgerond lijkt maar later verdwijnt, zou jouw verlies zijn, dus dat risico nemen we niet.

  6. 6

    Bijgeschreven, en je krijgt bericht

    De vergoeding wordt afgetrokken, de rest komt op je saldo terecht, en een ondertekende webhook met je order_id gaat naar je server.

Ongeveer een minuut van betaling tot callback: ongeveer vijftien seconden netwerkbevestigingen, de rest is onze doorloop van bewaakte adressen.

Waar het geld naartoe gaat#

De vergoeding, en waarover die wordt berekend.

De vergoeding is 0,2%, vastgesteld voor jouw winkel op het moment van registratie. Verandert het standaardtarief later, dan verandert het jouwe niet mee — het wordt in elke factuur vastgelegd als getal, niet als verwijzing naar een instelling.

De vergoeding wordt berekend over wat daadwerkelijk binnenkomt, niet over wat de factuur vroeg. Factureer 5 USDT en ontvang 20, dan wordt de vergoeding over 20 berekend. Onderbetaal, en ze wordt berekend over wat is binnengekomen.

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

Overbetaling wordt volledig bijgeschreven — wij houden het verschil niet in. Onderbetaling laat de factuur open zodat de koper naar hetzelfde adres kan bijstorten.

Munten van een ontvangstadres afhalen kost netwerkgas, en dat betalen wij — dat deel raakt jouw saldo nooit. Opnemen naar je eigen adres is iets anders: daar staat een eigen vergoeding tegenover, die wordt ingehouden op het bedrag dat je opvraagt, en de exacte getallen staan in het tarievenoverzicht.

Snel starten#

Vijf minuten tot je eerste factuur.

Vijf stappen. Twee zijn kliks in je accountomgeving, één is een enkel verzoek vanaf je server, en de laatste twee gebeuren vanzelf.

  1. 1

    Maak een winkel aan

    In je accountomgeving. Er worden direct betalingen geaccepteerd — geen wachttijd voor beoordeling. Verificatie gebeurt geruisloos op de achtergrond en beperkt alleen opnames, niet inkomende betalingen.

  2. 2

    Geef een API-sleutel uit

    Je winkel → API-sleutels → Nieuwe sleutel. De sleutel en het webhookgeheim worden eenmalig getoond en daarna nooit meer. Bewaar ze zoals je een databasewachtwoord bewaart, en stuur ze nooit naar een browser.

  3. 3

    Maak een factuur aan

    Eén verzoek vanaf je server, één link terug. De vier voorbeelden hieronder sturen precies hetzelfde.

  4. 4

    Stuur de koper naar payment_url

    Dat is de hele checkout — bedrag, adres, QR-code, aftelling, live status — en er is niets te bouwen. Zie Betaalpagina voor wat de koper daadwerkelijk ziet.

  5. 5

    Wacht op de webhook

    Zodra het geld on-chain is bevestigd en bijgeschreven, doen we een POST met een ondertekend payment.credited-event naar je server. Verifieer de handtekening en markeer de order dan als betaald — maar alleen wanneer data.status gelijk is aan paid of overpaid. Zie 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"
  }'

Stuur de koper door naar de payment_url in het antwoord. Je bent klaar — de rest komt binnen als webhook.

What to do next

Authenticatie#

Je API-sleutel, en hoe die wordt gebruikt.

Elk verzoek draagt je sleutel in de Authorization-header:

http
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA

Elke sleutel die hier wordt uitgegeven, begint met sk_live_. Het voorvoegsel sk_test_ bestaat alleen op een uitrol naar het testnetwerk, en zo'n uitrol bieden we niet aan — zie Testen. Wij bewaren een eenrichtingshash, niet de sleutel zelf, dus niemand, wij inbegrepen, kan hem je nog eens laten zien. Kwijt? Geef een nieuwe uit en trek de oude in.

De winkel wordt afgeleid uit de sleutel, waardoor geen enkel verzoek ooit een winkel-id nodig heeft. Een sleutel kan alleen handelen namens zijn eigen winkel.

Het pad bevat een versie: /api/merchant/v1/…. Binnen een versie voegen we alleen velden toe — er wordt niets hernoemd en niets verandert stilletjes van betekenis. Een wijziging die uw code zou breken krijgt een nieuw voorvoegsel, /v2, en /v1 blijft een aangekondigde periode werken.

Deze sleutel maakt facturen aan onder jouw naam. Houd hem server-side. Alles in browser-JavaScript is openbaar, hoe goed verborgen het er ook uitziet.

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.

Een factuur aanmaken#

POST /api/merchant/v1/invoices

POST/api/merchant/v1/invoices

Aanvraagbody

VeldTypeVerplichtBeschrijving
assetstringjaOfwel TON ofwel USDT_TON.
amountstringjaNormale eenheden van de munt, als string: "5" is 5 USDT. Niet meer decimalen dan de munt heeft. Zie Bedragen.
order_idstringneeJe eigen referentie, maximaal 200 tekens. Komt terug in elke webhook — zo koppel je een betaling aan een order.
descriptionstringneeMaximaal 1000 tekens. Getoond aan de koper op de betaalpagina.
ttl_minutesnumberneeHoe lang de factuur betaalbaar blijft, in minuten. 1–1440; laat je het weg, dan geldt de standaard — op dit moment 2 uur.
idempotency_keystringneeMaximaal 200 tekens. Stuur dezelfde waarde bij een herhaalde poging en je krijgt dezelfde factuur terug in plaats van een tweede. Een veld in de body, niet de Idempotency-Key-header — die header wordt hier niet gelezen.

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

Koppelen aan je order

VeldWat je ermee doet
invoice_idSla dit op bij je order. Hiermee wordt de betaling overal elders geïdentificeerd.
payment_urlStuur de koper hierheen door. Er is verder niets te bouwen.
addressAlleen als je je eigen checkout rendert. Toon het exact zoals gegeven — zie de waarschuwing hieronder.
amountHet bedrag in normale eenheden, precies zoals u het stuurde. Toon deze.
amount_minorHetzelfde bedrag als geheel getal in de kleinste eenheid. Reken hiermee.
expires_atToon een aftelling. Daarna wordt het adres niet meer bewaakt voor deze factuur.
statusHier altijd pending. Echte wijzigingen komen binnen via webhook.
Als je je eigen pagina rendert, druk het adres dan exact af zoals geretourneerd. Het is in non-bounceable vorm (UQ… op mainnet, 0Q… op testnet). Converteer het, verfraai het, of vervang het door een andere codering van hetzelfde adres, en munten die naar een nog niet uitgerolde wallet worden gestuurd, kaatsen terug naar de afzender.

Een factuur opvragen#

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

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

Zelfde vorm als hierboven, waarbij status, paid en paid_minor de huidige stand weergeven: paid is hoeveel er is binnengekomen in normale eenheden, paid_minor hetzelfde als geheel getal in de kleinste eenheid. Handig als fallback wanneer een webhook is gemist, of op een bedankpagina.

Poll hoogstens elke paar seconden, en behandel webhooks als het primaire kanaal. Facturen die aan een andere winkel toebehoren, antwoorden met 404, niet 403 — zodat een id niet kan worden afgetast op bestaan.

Een factuur annuleren#

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

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

Sluit een factuur die nog openstaat — pending of underpaid — en geeft het adres vrij. Gebruik dit wanneer de klant de checkout verlaat: adressen zijn een eindige grondstof, en ze teruggeven houdt de pool gezond.

Een factuur die niet meer openstaat, antwoordt met 409. Een underpaid-factuur annuleren stuurt niemand munten terug: geld dat al is bijgeschreven blijft op je saldo staan, en het enige wat sluit is de mogelijkheid om bij te storten.

Webhooks#

Wat er binnenkomt, en hoe je het verifieert.

Stel bij het aanmaken van een sleutel een webhook-URL in. We doen daar een POST naartoe wanneer een betaling is bijgeschreven — en wanneer een storting die voor een extra controle is vastgehouden, wordt afgewezen. Elke aflevering is ondertekend, en we blijven ongeveer anderhalve dag opnieuw proberen totdat je met 2xx antwoordt. Geef de goederen vrij bij status: paid of overpaid, niet bij het enkele binnenkomen van de aanroep.

Events

EventWanneerWat de body bevat
payment.creditedDe overboeking is on-chain bevestigd, onze fee is ingehouden en de rest staat op je saldo.De velden die hieronder staan.
payment.rejectedEen storting die voor een extra controle werd vastgehouden (zie Statusreferentie) is afgewezen. Het geld komt niet op je saldo.invoice_id, order_id, asset, amount, tx_hash en reason. Lever niet uit; was de factuur al paid door een eerdere overboeking, dan gaat dit event over de extra storting, niet over die betaling.

Wat er binnenkomt

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

Veldtoewijzing

VeldBetekenis
event_idUniek per event; zit ook in de X-Paysell-Event-Id-header. Sla het op en negeer herhalingen — zie hieronder.
data.order_idJe referentie. Zoek je order hiermee op.
data.amountWat de koper in deze overdracht stuurde, in de kleinste eenheid — anders dan de API, die normale eenheden aanneemt.
data.feeWat wij inhielden, in de kleinste eenheid.
data.creditedWat op je saldo belandde: amount − fee, in de kleinste eenheid.
data.paid_minorTotaal dat tot nu toe op deze factuur is ontvangen, in de kleinste eenheid. Het veld dat telt bij underpaid: de status zegt dat er minder binnenkwam, dit zegt hoeveel minder.
data.assetDe munt die daadwerkelijk binnenkwam. Niet per se de munt waar de factuur om vroeg.
data.asset_mismatchAlleen aanwezig, en dan true, wanneer de binnengekomen munt niet die van de factuur is. Het geld wordt je bijgeschreven, maar de factuur blijft onbetaald en status wordt nooit paid.
data.invoice_assetKomt samen met asset_mismatch: de munt die de factuur werkelijk vraagt.
data.statusDe huidige status van de factuur: pending, underpaid, paid, overpaid of expired. Vergelijk met wat je verwachtte.
data.tx_hashDe on-chain transactie, voor je administratie en support.

Headers bij elke aflevering

http
X-Paysell-Event:      payment.credited
X-Paysell-Event-Id:   99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp:  1789000000
X-Paysell-Signature:  sha256=6f1c0e6a…
HeaderBetekenis
X-Paysell-EventHet type event: payment.credited of payment.rejected.
X-Paysell-Event-IdUniek per event. Op deze waarde ontdubbel je.
X-Paysell-TimestampWanneer wij ondertekenden, in unix-seconden. Het maakt deel uit van de ondertekende string.
X-Paysell-Signaturesha256= gevolgd door de HMAC in hex. Zie hieronder.

De handtekening verifiëren

Elk verzoek wordt ondertekend met het webhookgeheim dat eenmalig werd getoond toen je de sleutel aanmaakte. De handtekening is HMAC-SHA256(secret, "{timestamp}.{raw_body}") — de timestamp uit X-Paysell-Timestamp, een letterlijke punt, dan de bytes van de body. Controleer dit voordat je actie onderneemt: zonder deze controle kan iedereen die je URL achterhaalt je een betaalde order voorschotelen.

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

Onderteken de ruwe body-bytes, precies zoals ontvangen. Parse je de JSON en serialiseer je die opnieuw, dan veranderen de bytes — sleutelvolgorde, spatiëring — en komt de handtekening niet meer overeen. Vergelijk in constante tijd (hmac.compare_digest, crypto.timingSafeEqual): een gewone == keert sneller terug bij een verkeerde eerste byte, en dat verschil is genoeg om een handtekening byte voor byte te raden.

Het tijdstempelvenster

Weiger alles waarvan de timestamp meer dan vijf minuten van je eigen klok afligt, in beide richtingen. De timestamp zit juist in de ondertekende string zodat hij niet kan worden aangepast zonder de handtekening te breken; het venster maakt daar pas bescherming van. Zonder venster blijft een eenmaal onderschept verzoek voor altijd geldig en kan het op elk moment opnieuw worden afgespeeld — de handtekening alleen verloopt nooit. Houd de klok van je server op NTP, anders gaat deze controle goede afleveringen weigeren.

Duplicaten

Hetzelfde event kan meer dan eens binnenkomen. Dat is geen bug: we proberen het opnieuw totdat je met 2xx antwoordt, en een aflevering die slaagde maar waarvan het antwoord ons nooit bereikte, wordt opnieuw verstuurd. Registreer X-Paysell-Event-Id (die komt ook als event_id in de body) en zorg dat de tweede aankomst niets doet.

Herhaalde pogingen

De eerste poging gaat de deur uit zodra de betaling is bijgeschreven. Mislukt die — time-out, geweigerde verbinding, TLS-fout, een redirect, of welke niet-2xx-status dan ook — dan proberen we het opnieuw volgens een vast schema:

1 min → 5 min → 15 min → 1 u → 6 u → 24 u

Zeven pogingen in totaal, verspreid over ruwweg 31 uur. De eerste liggen dicht op elkaar omdat de gebruikelijke oorzaak een ontvanger is die aan het herstarten was en alweer terug is; de late zijn schaars omdat beuken op een server die al een dag plat ligt niemand helpt.

Na de laatste poging wordt de aflevering gemarkeerd als dropped en stoppen we uit onszelf. Ze is niet verloren: de betaalregel in je accountomgeving toont de stand, het aantal pogingen en de foutsoort, met een knop Opnieuw versturen die een verse ronde van alle zeven pogingen start. Je andere redmiddel is GET /api/merchant/v1/invoices/{invoice_id} — de factuur kent altijd haar eigen status.

Hoe een webhook-URL eruit moet zien

De URL wordt gecontroleerd wanneer je hem opslaat, en opnieuw vóór elke afzonderlijke aflevering. Een URL die de controle niet doorstaat, krijgt bij het opslaan 422 en code: "webhook_url_rejected" terug, en markeert de aflevering als failed — zonder herhaalde pogingen — als hij pas later gaat falen. De regels:

  • Alleen `https://`, en poort 443. Een webhook draagt betaalgegevens; over gewone http zijn die leesbaar voor iedereen onderweg.
  • Een domeinnaam, geen IP-adres. Je hebt sowieso een certificaat nodig, en certificaten worden niet uitgegeven voor kale IP's.
  • Geen `localhost`, en geen .local-, .internal-, .corp-, .lan- of .test-naam — onze servers kunnen jouw netwerk niet bereiken, en een naam die binnen het onze resolvet is precies wat we niet mogen aanroepen.
  • Geen inloggegevens in de URL (https://user:pass@…). Zet je eigen token in het pad of in een queryparameter als je er een nodig hebt.
  • Elk adres waarnaar de naam resolvet moet publiek zijn — zowel A als AAAA. Private, loopback-, link-local- en CGNAT-reeksen worden geweigerd, en de controle wordt vóór elke aflevering herhaald, dus het record later op 127.0.0.1 richten werkt evenmin.
  • Een redirect is een mislukking, geen tussenstap. We volgen ze niet: het adres dat je ons gaf is gecontroleerd, dat in een Location-header niet.
De controle gebeurt met opzet twee keer — één keer wanneer je de URL opslaat, zodat een typefout meteen wordt gemeld in plaats van door stille niet-aflevering, en één keer vóór elke verzending, omdat de eigenaar van een domein dat op elk moment naar een intern adres kan omleiden. Verhuist je endpoint, werk dan eerst de sleutel bij: een geweigerde URL levert niets af en zet niets in de wachtrij.

Antwoord snel

Elke 2xx volstaat, binnen tien seconden — dat is onze hele time-out, verbinding inbegrepen. Antwoord eerst, doe het trage werk daarna; een endpoint dat op zijn eigen database wacht voordat het antwoordt, wordt vroeg of laat als time-out geboekt en opnieuw geprobeerd, en dan verwerk je hetzelfde event twee keer. Al het andere — een 4xx, een 5xx, een redirect, een hangende verbinding — telt als mislukte poging en gaat terug in het schema hierboven.

Aflevering, eerlijk gezegd

Wat vaststaat is het afleveringsmechanisme: zeven pogingen over ruwweg 31 uur, handmatig opnieuw versturen vanuit je accountomgeving, en een factuur-endpoint dat altijd de echte status kent. Bouw de flow zo dat een webhook die nooit aankomt je niets kost — lees de factuur op je bedankpagina, of stem openstaande facturen eens per uur af. Webhooks zijn de snelle weg, niet de enige weg.

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.

Statusreferentie#

Elke factuur- en betaalstatus, uitgelegd.

Factuur

StatusBetekenisWat te doen
pendingWacht op betaling.Houd de order open.
paidVolledig betaald.Geef de goederen vrij.
overpaidEr kwam meer binnen dan gevraagd. Het overschot wordt volledig aan jou bijgeschreven.Geef de goederen vrij; restitueer het verschil als je wilt.
underpaidEr kwam minder binnen dan gevraagd. De factuur blijft open en houdt haar adres: de koper kan naar dezelfde plek bijstorten, en paid_minor zegt hoeveel er al binnen is. Ze blijft betaalbaar gedurende de rest van haar levensduur plus een respijtperiode van 24 uur na expires_at.Wacht op de bijstorting, of maak afspraken met de klant. Geef de goederen niet vrij — de factuur is niet betaald.
expiredHet venster is gesloten, respijtperiode inbegrepen. Kan nog geld bevatten: wat er binnenkwam bleef op je saldo staan, en paid_minor zegt hoeveel.Bied een nieuwe factuur aan. Accepteer geen betaling op het oude adres: zodra een factuur verloopt gaat het adres terug de pool in, en een zeer late overboeking is een supportzaak in plaats van een automatische bijschrijving. Controleer paid_minor voordat je de klant vertelt dat er niets is ontvangen.
cancelledDoor jou geannuleerd. Het adres gaat terug naar de pool.Niets.

Betaling

Zichtbaar in je accountomgeving; handig bij ondersteuning van een klant tijdens het betalen.

StatusBetekenis
detectedGezien op de chain, wacht op bevestigingen.
confirmedHet netwerk heeft het bevestigd. Bijschrijving volgt.
creditedOp je saldo. Dit is het moment waarop de webhook afgaat.
reviewVastgehouden voor een extra controle — bijvoorbeeld munten die op een adres zonder openstaande factuur binnenkomen.
rejectedNiet bijgeschreven. De reden wordt vastgelegd.

Wanneer een betaling naar `review` gaat

Sommige stortingen worden voor een extra controle vastgehouden in plaats van meteen te worden bijgeschreven: een ongewoon groot bedrag, munten die binnenkomen op een adres zonder openstaande factuur, of de twee blockchainbronnen die we bevragen die het oneens zijn over wat er gebeurde. Er gaat niets verloren — het geld wacht op een beslissing en de webhook gaat af zodra die er is, wat minuten of uren later kan zijn. Behandel een ontbrekende callback bij een betaling die als review staat als normaal, niet als storing. Is het van belang voor een order, vraag het dan aan support onder vermelding van de tx_hash.

Bedragen#

Normale eenheden eruit, kleinste eenheden terug.

Stuur bedragen in de normale eenheden van de munt, als string"1.5" is anderhalf. Geen JSON-getal en niet de kleinste eenheid.

AssetDecimalenU stuurtamount_minor in het antwoord
TON9"1.5""1500000000"
USDT_TON6"1.5""1500000"

Een string en geen getal, omdat JSON-getallen IEEE-754-doubles zijn en een groot bedrag in nanoton daarin niet meer exact past. Meer decimalen dan de munt heeft levert 422 op, nooit een stille afronding van uw geld. Webhooks werken andersom: daar zijn amount, fee en credited gehele getallen in de kleinste eenheid, want die kant wordt door code gelezen, niet door een mens.

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

Limieten#

Minima, maxima en snelheidslimieten.

LimietWaardeBij overschrijding
Minimale factuur0.1 TON · 3 USDT422
Maximale factuur7000 TON · 10000 USDT422
Facturen per uur, per winkel60429
Gelijktijdig openstaande facturen20, groeiend met elke betaalde factuur, tot 200429
Levensduur factuur1 minuut – 24 uur (standaard 2 uur)422
API-verzoeken per sleutel120 per minuut429 + Retry-After

Het minimum is geen bureaucratie. Onze vergoeding is een percentage, maar het innen van een betaling kost een vast bedrag: USDT van een ontvangstadres afhalen betekent het eerst van gas voorzien, uit onze eigen zak. Onder een paar dollar dekt de vergoeding de afhandeling niet, en zo'n betaling accepteren zou betekenen dat we je geld bijschrijven dat oneconomisch is om te verplaatsen.

Het maximum gaat niet over grote handelaren — het is een val voor een fout in de eenheden. Stuur "5000000" waar je "5" bedoelde en je kreeg anders een factuur van vijf miljoen dollar: de koper ziet een absurd bedrag en vertrekt. Een echte bestelling raakt dit plafond nooit; een fout altijd. Beide plafonds zijn instellingen (invoice_max_ton, invoice_max_usdt) en kunnen voor jouw winkel worden verhoogd — vraag ernaar.

Het uurmaximum en het maximum aan openstaande facturen beschermen allebei de adressenpool. Elke openstaande factuur houdt een ontvangstadres bezet, en een op hol geslagen lus op één site zou de pool anders voor iedereen leegtrekken. Een nieuwe winkel mag 20 facturen tegelijk open hebben; die ruimte groeit met één voor elke factuur die daadwerkelijk is geïnd, tot een plafond van 200. underpaid telt als open — die houdt zijn adres nog vast, in afwachting van de rest. Een verlaten factuur annuleren geeft het adres meteen terug. Herhaalde pogingen met dezelfde idempotency_key tellen niet mee voor het uurmaximum.

De verzoeklimiet is 120 per minuut per API-sleutel — twee aanroepen per seconde, ruim boven elke echte orderstroom. Een 429 draagt een Retry-After-header in seconden: wacht die tijd af in plaats van in een strakke lus opnieuw te proberen, want dat schuift het venster alleen maar verder op.

Fouten#

De statuscodes die je daadwerkelijk zult zien.

Fouten komen terug als JSON, in twee vormen. Alles wat wij of de verwerkingskern beslissen, zet een paar {code, message} onder detail. Een aanvraagbody die de validatie niet doorstaat, zet daar in plaats daarvan een lijst met veldfouten neer. Controleer welke van de twee je kreeg voordat je detail.code leest — en vertak op `code`, nooit op `message`: de formulering kan elk moment veranderen, de code niet.

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
    }
  ]
}
StatusWanneerWat te doen
401Sleutel ontbreekt, is onjuist, of ingetrokken.Controleer de header. Geef een nieuwe sleutel uit als deze werd ingetrokken.
404Geen zo'n factuur, of hij hoort bij een andere winkel.Controleer het id. De twee gevallen antwoorden met opzet hetzelfde, zodat een id niet kan worden afgetast.
409De factuur verkeert in een toestand die dit verbiedt.Lees eerst de huidige status.
422Het verzoek is onjuist opgebouwd, of het bedrag valt buiten de factuurgrenzen.Het bericht noemt zowel de verzonden waarde als de limiet.
429Te veel facturen dit uur, te veel tegelijk open, of te veel verzoeken.Wacht Retry-After af en probeer het dan opnieuw.
502We konden de verwerkingskern niet bereiken.Probeer het opnieuw met dezelfde idempotency-sleutel.

Codes

De door ons besliste vorm is {"detail": {"code": …, "message": …}}. Dit zijn de codes die de merchant-API teruggeeft.

CodeStatusBetekenis
invalid_api_key401De sleutel ontbreekt, is onjuist opgebouwd, onbekend of ingetrokken. Alle vier gevallen antwoorden hetzelfde, zodat een sleutel niet kan worden afgetast.
not_found404Geen zo'n object, of het hoort bij een andere winkel.
invalid_input422Het verzoek doorstond de validatie in de kern niet — een ongeldig bedrag, te veel decimalen, een bedrag buiten de factuurgrenzen.
conflict409De actie is in tegenspraak met de huidige toestand, zoals het annuleren van een factuur die niet meer openstaat.
too_many_requests429Een snelheidslimiet: facturen per uur, openstaande facturen, of verzoeken per minuut. Retry-After zegt hoe lang je moet wachten.
cbc_unreachable502We konden de verwerkingskern niet bereiken. Probeer het opnieuw met dezelfde idempotency_key.
webhook_url_rejected422Alleen bij het opslaan van een sleutel: de webhook-URL doorstond de controles hierboven niet. detail.reason noemt welke regel — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials, enzovoort.

Een 502 betekent niet dat de factuur niet is aangemaakt — het verzoek is mogelijk doorgekomen, met het antwoord onderweg terug verloren. Probeer het opnieuw met dezelfde idempotency_key en je krijgt óf de bestaande factuur óf een nieuwe, nooit twee.

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.

Terugbetalingen#

Hoe je een klant terugbetaalt.

Terugbetalingen lopen via support, niet via een API-aanroep. Een terugbetaling is een nieuwe overdracht naar een adres dat een mens heeft opgegeven, en een betalingsverwerker die op een API-aanroep automatisch geld terugstuurt, is een betalingsverwerker die je geld naar het adres van een aanvaller kunt laten sturen. Daarom is het bewust handwerk.

Om een koper terug te betalen open je vanuit je accountomgeving een supportticket met de invoice_id of tx_hash, het bedrag, en het adres waarnaartoe moet worden gestuurd. Een medewerker controleert de betaling, haalt het geld van je saldo af, en antwoordt in hetzelfde ticket. Reken op een werkdag, niet op een minuut.

Twee gevolgen waar je je ontwerp op moet afstemmen. Overbetaling wordt volledig aan jou bijgeschreven — wij houden er niets van in — dus het verschil teruggeven aan een koper die te veel stuurde is jouw keuze en loopt langs dezelfde weg. En een onderbetaalde factuur is geen terugbetalingsgeval zolang ze open staat: het geld staat op je saldo, het adres wordt nog bewaakt, en de koper kan gewoon bijstorten. Pas na de respijtperiode, wanneer de factuur expired wordt met geld eraan, is er iets te beslissen.

Testen#

Hoe je je integratie test voor de lancering.

De sleutels hier zijn live: elke uitgegeven sleutel is een sk_live_-sleutel tegen de productiekern en het TON-mainnet. Er is geen aparte testomgeving, en dat heeft een voordeel: je legt precies het pad af dat je echte bestellingen zullen nemen.

Test dus zoals je alles test wat echt geld raakt: met kleine bedragen. Maak een factuur voor het minimum (0.1 TON of 3 USDT), betaal die vanuit je eigen wallet, en volg het hele pad — de betaalpagina, de webhook, de handtekeningcontrole, je order die omslaat naar betaald. De vergoeding geldt, en de munten bewegen echt.

De onderdelen die je kunt uitproberen zonder iets uit te geven: een factuur aanmaken en opvragen, er een annuleren, de 422 bij een onjuist bedrag, de 401 bij een verkeerde sleutel, en je eigen handtekeningcontrole — onderteken een voorbeeldbody met je geheim en voer die aan je eigen handler. Wat echt een betaling vereist is alleen de laatste stap: een daadwerkelijke payment.credited-webhook.

Bouw de integratie zo dat die niet afhangt van een sandbox of een gesimuleerde betaling: het live pad controleer je sneller — en eerlijker.

Behandel je eerste live order als de echte test: kies een klein bedrag, houd de factuur open in het dashboard, en controleer de betaalregel en de webhookstatus voordat je er echte klanten naartoe stuurt.

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 voor livegang#

Tien punten om te controleren vóór de lancering.

  • Sleutel staat alleen server-side, nooit in browser-JavaScript.
  • Webhookhandtekening wordt geverifieerd tegen "{timestamp}.{raw_body}", in constante tijd.
  • Afleveringen ouder dan vijf minuten worden geweigerd, en de klok van de server staat op NTP.
  • Een herhaalde X-Paysell-Event-Id doet de tweede keer niets.
  • Webhook antwoordt binnen tien seconden met 2xx; traag werk gebeurt daarna.
  • De webhook-URL is een https://-domein op poort 443, zonder redirect ervoor.
  • Een gemiste webhook is te overleven: het factuur-endpoint wordt gelezen op de bedankpagina of bij een afstemmingsronde.
  • idempotency_key wordt eenmaal per order gegenereerd en hergebruikt bij herhalingen.
  • Bedragen gaan als strings in normale eenheden de deur uit; webhookgetallen worden als kleinste eenheden gelezen.
  • Adres wordt exact zoals geretourneerd getoond, ongewijzigd.
  • overpaid en underpaid worden afgehandeld, niet alleen paid; expired kan nog paid_minor bevatten.
  • Goederen worden vrijgegeven bij status: paid of overpaid, nooit op het enkele binnenkomen van de callback.
  • 429 wordt afgehandeld door Retry-After af te wachten, niet door meteen opnieuw te proberen.
  • Saldi worden bij ons gelezen, niet apart bijgehouden als waarheid.

Iets onduidelijk?

Als deze pagina je vraag niet heeft beantwoord, is dat een gat in de documentatie en de moeite waard om te melden. Schrijf ons vanuit je accountomgeving en we verbeteren de pagina, niet alleen het antwoord.