Krypto-Zahlungen akzeptieren
Paysell rechnet TON und USDT im TON-Netzwerk ab. Sie erstellen eine Rechnung, wir geben Ihnen einen Link, und Sie erhalten einen signierten Callback, sobald das Geld on-chain bestätigt und Ihrem Guthaben gutgeschrieben wurde.
Überblick#
Was Paysell macht, und was nicht.
Paysell ist ein Zahlungsdienstleister, keine Wallet. Sie haben nie mit privaten Schlüsseln zu tun, überwachen nicht die Blockchain und entscheiden nicht, wann eine Transaktion endgültig ist — das übernehmen wir.
Jede Rechnung erhält eine eigene Empfangsadresse. Sobald ein Käufer sie bezahlt, warten wir auf die Bestätigung der Überweisung durch das Netzwerk, ziehen unsere Gebühr ab und schreiben den Rest Ihrem Guthaben gut. Sie zahlen an eine beliebige Adresse aus.
Wie ein Zahlungsvorgang abläuft#
Sechs Schritte, die meisten bei uns.
Sechs Schritte, die meisten bei uns:
- 1
Ihr Kunde klickt auf Bezahlen
Ihr Server ruft unsere API mit dem Betrag und Ihrer eigenen Bestellreferenz auf.
- 2
Wir vergeben eine Adresse
Eine frische Empfangsadresse wird aus einem vorbereiteten Pool entnommen und dieser Rechnung zugeordnet. Eine Adresse gehört genau zu einer offenen Rechnung — so wird eine Zahlung ihr zugeordnet.
- 3
Der Kunde sendet die Coins
Er scannt den QR-Code oder kopiert die Adresse. Schicken Sie ihn zur
payment_url, die wir zurückgeben — die Seite übernimmt alles: Betrag, Adresse, QR-Code, Countdown, Live-Status. - 4
Wir erkennen den Transfer
Zwei unabhängige Blockchain-Datenquellen werden abgefragt und ihre Antworten verglichen. Bei Abweichungen halten wir an, statt die bequemere Antwort zu wählen.
- 5
Wir warten auf Finalität
Aufnahme in die Masterchain plus drei Blöcke darüber. Etwa fünfzehn Sekunden — eine Zahlung, die abgeschlossen aussieht und später verschwindet, wäre Ihr Verlust, dieses Risiko gehen wir nicht ein.
- 6
Gutgeschrieben, und Sie werden informiert
Die Gebühr wird abgezogen, der Rest landet auf Ihrem Guthaben, und ein signierter Webhook geht mit Ihrer
order_idan Ihren Server.
Von der Zahlung bis zum Callback: etwa eine Minute — rund fünfzehn Sekunden Netzwerkbestätigungen, der Rest ist unser Durchlauf der überwachten Adressen.
Wohin das Geld fließt#
Die Gebühr, und wovon sie berechnet wird.
Die Gebühr beträgt 0,2 %, festgelegt für Ihren Shop zum Zeitpunkt der Registrierung. Ändert sich der Standardsatz später, ändert sich Ihrer nicht — er wird in jede Rechnung als Zahl geschrieben, nicht als Verweis auf eine Einstellung.
Die Gebühr wird von dem, was tatsächlich ankommt, berechnet, nicht von dem, was die Rechnung verlangte. Stellen Sie 5 USDT in Rechnung und erhalten 20, wird die Gebühr auf 20 berechnet. Bei Unterzahlung wird sie auf das Angekommene berechnet.
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Überzahlungen werden vollständig gutgeschrieben — wir behalten die Differenz nicht. Unterzahlung lässt die Rechnung offen, damit der Käufer an dieselbe Adresse nachzahlen kann.
Schnellstart#
Fünf Minuten bis zu Ihrer ersten Rechnung.
Fünf Schritte. Zwei sind Klicks in Ihrem Kundenbereich, einer ist eine einzige Anfrage von Ihrem Server, und die letzten beiden geschehen von selbst.
- 1
Shop anlegen
In Ihrem Kundenbereich. Er akzeptiert sofort Zahlungen — ohne auf eine Prüfung zu warten. Die Verifizierung läuft still im Hintergrund und beschränkt nur Auszahlungen, nicht eingehende Zahlungen.
- 2
API-Schlüssel ausstellen
Ihr Shop → API-Schlüssel → Neuer Schlüssel. Der Schlüssel und das Webhook-Secret werden einmal angezeigt und nie wieder. Bewahren Sie sie auf wie ein Datenbankpasswort und schicken Sie sie nie an einen Browser.
- 3
Rechnung erstellen
Eine Anfrage von Ihrem Server, ein Link zurück. Die vier Beispiele unten senden alle exakt dasselbe.
- 4
Den Käufer zur
payment_urlschickenDas ist der gesamte Checkout — Betrag, Adresse, QR-Code, Countdown, Live-Status — und es gibt nichts zu bauen. Was der Käufer tatsächlich sieht, steht unter Checkout.
- 5
Auf den Webhook warten
Sobald das Geld on-chain bestätigt und gutgeschrieben ist, senden wir per POST ein signiertes
payment.credited-Ereignis an Ihren Server. Prüfen Sie die Signatur und markieren Sie die Bestellung dann als bezahlt — aber nur, wenndata.statuspaidoderoverpaidist. Siehe 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"
}'Leiten Sie den Käufer zur payment_url aus der Antwort weiter. Fertig — der Rest kommt als Webhook.
What to do next
- Write the webhook receiver — Webhooks and A complete receiver. Nothing else on this page matters as much: it is what turns a payment into a paid order.
- Handle
underpaidandoverpaid, not justpaid— see Status reference. - Read Typical mistakes, then walk the go-live checklist before you point real customers at it.
Authentifizierung#
Ihr API-Schlüssel, und wie er verwendet wird.
Jede Anfrage trägt Ihren Schlüssel im Authorization-Header:
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxAJeder hier ausgestellte Schlüssel beginnt mit sk_live_. Das Präfix sk_test_ existiert nur in einer Installation gegen das Testnetz, und eine solche Installation wird nicht angeboten — siehe Testen. Wir speichern einen Einweg-Hash, nicht den Schlüssel selbst, sodass ihn Ihnen niemand, auch wir nicht, erneut zeigen kann. Verloren? Stellen Sie einen neuen aus und widerrufen Sie den alten.
Der Shop wird aus dem Schlüssel abgeleitet, deshalb nimmt keine Anfrage eine Shop-ID entgegen. Ein Schlüssel kann nur für seinen eigenen Shop handeln.
Der Pfad trägt eine Version: /api/merchant/v1/…. Innerhalb einer Version fügen wir nur Felder hinzu — nichts wird umbenannt, nichts ändert stillschweigend seine Bedeutung. Eine Änderung, die Ihren Code brechen würde, bekommt ein neues Präfix, /v2, und /v1 läuft eine angekündigte Zeit weiter.
Endpoints at a glance#
Four calls, three of them authenticated.
This is the whole merchant API. Balances, payouts and history are not in it — they live in your account area, where a person is looking at them.
| Endpoint | Method | Auth | What it does |
|---|---|---|---|
| /invoices | POST | API key | Open an invoice and get a payment link. Details. |
| /invoices/{invoice_id} | GET | API key | Read one invoice's current state. Details. |
| /invoices/{invoice_id}/cancel | POST | API key | Close an invoice that is still open and free its address. Details. |
| /public/invoices/{invoice_id} | GET | none | What the hosted checkout page reads. Only needed if you build your own. Details. |
Every path is relative to https://paysell.me/api/merchant/v1. There is no list endpoint and no refund endpoint — see Refunds.
Rechnung erstellen#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoicesRequest-Body
| Feld | Typ | Erforderlich | Beschreibung |
|---|---|---|---|
| asset | string | ja | Entweder TON oder USDT_TON. |
| amount | string | ja | Normale Einheiten der Coin, als String: "5" sind 5 USDT. Nicht mehr Nachkommastellen, als die Coin hat. Siehe Beträge. |
| order_id | string | nein | Ihre eigene Referenz, bis zu 200 Zeichen. Kommt in jedem Webhook zurück — so ordnen Sie eine Zahlung einer Bestellung zu. |
| description | string | nein | Bis zu 1000 Zeichen. Wird dem Käufer auf der Zahlungsseite angezeigt. |
| ttl_minutes | number | nein | Wie lange die Rechnung bezahlbar bleibt, in Minuten. 1–1440; lassen Sie das Feld weg, gilt der Standardwert — heute 2 Stunden. |
| idempotency_key | string | nein | Bis zu 200 Zeichen. Senden Sie beim Wiederholen denselben Wert, und Sie erhalten dieselbe Rechnung zurück statt einer zweiten. Ein Feld im Body, nicht der Header Idempotency-Key — dieser Header wird hier nicht gelesen. |
Antwort · 201
{
"invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
"payment_url": "https://paysell.me/pay/12c22c1a-a496-4c1e-abe3-72661ef8706e",
"address": "UQAvDJp7QDwqRcuNQBiK2GhBt71Xh1_UMYPCzMkQAoBPmZKl",
"asset": "USDT_TON",
"amount": "5",
"amount_minor": "5000000",
"status": "pending",
"paid": "0",
"paid_minor": "0",
"order_id": "order-1042",
"description": "Pro subscription",
"expires_at": "2026-09-06T17:20:55Z",
"created_at": "2026-09-06T15:20:55Z"
}Einordnung in Ihre Bestellung
| Feld | Was damit zu tun ist |
|---|---|
| invoice_id | Speichern Sie sie bei Ihrer Bestellung. Damit wird die Zahlung überall sonst identifiziert. |
| payment_url | Leiten Sie den Käufer hierhin weiter. Sonst gibt es nichts zu bauen. |
| address | Nur wenn Sie Ihre eigene Checkout-Seite bauen. Zeigen Sie sie exakt wie erhalten an — siehe Warnung unten. |
| amount | Der Betrag in normalen Einheiten, genau wie gesendet. Diesen anzeigen. |
| amount_minor | Derselbe Betrag als ganze Zahl in der kleinsten Einheit. Damit rechnen. |
| expires_at | Zeigen Sie einen Countdown. Danach wird die Adresse für diese Rechnung nicht mehr überwacht. |
| status | Hier immer pending. Echte Änderungen kommen per Webhook. |
UQ… im Mainnet, 0Q… im Testnet). Wandeln Sie sie um, verschönern Sie sie oder tauschen Sie sie gegen eine andere Kodierung derselben Adresse, springen Coins, die an eine noch nicht deployte Wallet gesendet wurden, zum Absender zurück.Rechnung abrufen#
GET /api/merchant/v1/invoices/{invoice_id}
/api/merchant/v1/invoices/{invoice_id}Gleiche Form wie oben, wobei status, paid und paid_minor die Gegenwart widerspiegeln: paid ist die bisher eingegangene Summe in normalen Einheiten, paid_minor dieselbe Summe als ganze Zahl in der kleinsten Einheit. Nützlich als Rückfalloption, wenn ein Webhook verpasst wurde, oder auf einer Dankesseite.
Fragen Sie höchstens alle paar Sekunden ab, und betrachten Sie Webhooks als den primären Kanal. Rechnungen, die einem anderen Shop gehören, antworten mit 404 — nicht 403, sodass eine ID nicht auf Existenz abgefragt werden kann.
Rechnung stornieren#
POST /api/merchant/v1/invoices/{invoice_id}/cancel
/api/merchant/v1/invoices/{invoice_id}/cancelSchließt eine Rechnung, die noch offen ist — pending oder underpaid —, und gibt ihre Adresse frei. Nutzen Sie dies, wenn der Kunde den Checkout abbricht: Adressen sind eine endliche Ressource, und sie zurückzugeben hält den Pool gesund.
Eine Rechnung, die nicht mehr offen ist, antwortet mit 409. Das Stornieren einer underpaid-Rechnung erstattet niemandem Coins: Bereits gutgeschriebenes Geld bleibt auf Ihrem Guthaben, und geschlossen wird lediglich die Annahme einer Nachzahlung.
Webhooks#
Was ankommt, und wie man es überprüft.
Hinterlegen Sie beim Anlegen eines Schlüssels eine Webhook-URL. Wir senden dorthin ein POST, wenn eine Zahlung gutgeschrieben wird — und wenn eine für eine zusätzliche Prüfung zurückgehaltene Einzahlung abgelehnt wird. Jede Zustellung ist signiert, und wir wiederholen sie etwa anderthalb Tage lang, bis Sie mit 2xx antworten. Geben Sie die Ware bei status: paid oder overpaid frei, nicht schon beim bloßen Eintreffen des Aufrufs.
Ereignisse
| Ereignis | Wann | Was im Body steht |
|---|---|---|
| payment.credited | Die Überweisung ist in der Blockchain bestätigt, unsere Gebühr ist einbehalten, und der Rest liegt auf Ihrem Guthaben. | Die unten aufgeführten Felder. |
| payment.rejected | Eine für eine zusätzliche Prüfung zurückgehaltene Einzahlung (siehe Statusreferenz) wurde abgelehnt. Das Geld erreicht Ihr Guthaben nicht. | invoice_id, order_id, asset, amount, tx_hash und reason. Geben Sie die Ware nicht frei; war die Rechnung durch eine frühere Überweisung bereits paid, betrifft dieses Ereignis die zusätzliche Einzahlung und nicht jene Zahlung. |
Was ankommt
{
"event_id": "99f74f58-efbb-4af1-b0a3-76b0073f9e6b",
"type": "payment.credited",
"data": {
"invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
"order_id": "order-1042",
"asset": "USDT_TON",
"amount": "5000000",
"credited": "4905000",
"fee": "95000",
"status": "paid",
"paid_minor": "5000000",
"tx_hash": "97a1f0…"
}
}{
"event_id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d",
"type": "payment.rejected",
"data": {
"invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
"order_id": "order-1042",
"asset": "USDT_TON",
"amount": "5000000",
"tx_hash": "97a1f0…",
"reason": "could not be matched to any order"
}
}Feldzuordnung
| Feld | Bedeutung |
|---|---|
| event_id | Eindeutig pro Ereignis; steht auch im Header X-Paysell-Event-Id. Speichern Sie ihn und ignorieren Sie Wiederholungen — siehe unten. |
| data.order_id | Ihre Referenz. Suchen Sie Ihre Bestellung damit. |
| data.amount | Was der Käufer in dieser Überweisung gesendet hat, in der kleinsten Einheit — anders als die API, die normale Einheiten entgegennimmt. |
| data.fee | Was wir einbehalten haben, in der kleinsten Einheit. |
| data.credited | Was auf Ihrem Guthaben gelandet ist: amount − fee, in der kleinsten Einheit. |
| data.paid_minor | Bisher insgesamt auf dieser Rechnung eingegangen, in der kleinsten Einheit. Das entscheidende Feld bei underpaid: Der Status sagt, dass weniger ankam, dieses Feld sagt, wie viel weniger. |
| data.asset | Die Coin, die tatsächlich angekommen ist. Nicht zwingend die Coin, die die Rechnung verlangt hat. |
| data.asset_mismatch | Nur vorhanden, und dann true, wenn die angekommene Coin nicht die der Rechnung ist. Das Geld wird Ihnen gutgeschrieben, die Rechnung bleibt aber unbezahlt und status wird nie paid. |
| data.invoice_asset | Kommt zusammen mit asset_mismatch: die Coin, die die Rechnung tatsächlich verlangt. |
| data.status | Der aktuelle Status der Rechnung: pending, underpaid, paid, overpaid oder expired. Vergleichen Sie ihn mit dem Erwarteten. |
| data.tx_hash | Die On-Chain-Transaktion, für Ihre Unterlagen und den Support. |
Header bei jeder Zustellung
X-Paysell-Event: payment.credited
X-Paysell-Event-Id: 99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp: 1789000000
X-Paysell-Signature: sha256=6f1c0e6a…| Header | Bedeutung |
|---|---|
| X-Paysell-Event | Der Ereignistyp: payment.credited oder payment.rejected. |
| X-Paysell-Event-Id | Eindeutig pro Ereignis. Auf diesen Wert wird dedupliziert. |
| X-Paysell-Timestamp | Zeitpunkt unserer Signatur, in Unix-Sekunden. Er ist Teil der signierten Zeichenkette. |
| X-Paysell-Signature | sha256= gefolgt vom HMAC in Hex. Siehe unten. |
Signatur überprüfen
Jede Anfrage wird mit dem Webhook-Secret signiert, das beim Erstellen des Schlüssels einmalig angezeigt wird. Die Signatur ist HMAC-SHA256(secret, "{timestamp}.{raw_body}") — der Zeitstempel aus X-Paysell-Timestamp, ein wörtlicher Punkt, dann die Body-Bytes. Prüfen Sie sie, bevor Sie handeln: Sonst kann Ihnen jeder, der Ihre URL kennt, eine bezahlte Bestellung unterschieben.
Python:
import hmac, hashlib, time
def is_ours(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
if abs(time.time() - int(timestamp)) > 300: # ±5 minutes
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# compare_digest, not ==: a plain comparison leaks the answer through timing
return hmac.compare_digest("sha256=" + expected, signature)Node.js:
const crypto = require("node:crypto")
function isOurs(body, signature, timestamp, secret) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false // ±5 min
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(timestamp + ".").update(body) // body is the raw Buffer, not a parsed object
.digest("hex")
const a = Buffer.from(expected), b = Buffer.from(signature)
// timingSafeEqual throws when the lengths differ, so check that first
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Signieren Sie die rohen Body-Bytes, exakt wie erhalten. Parsen Sie das JSON und serialisieren Sie es neu, ändern sich die Bytes — Schlüsselreihenfolge, Leerzeichen — und die Signatur passt nicht mehr. Vergleichen Sie in konstanter Zeit (hmac.compare_digest, crypto.timingSafeEqual): Ein einfaches == kehrt bei einem falschen ersten Byte schneller zurück, und dieser Unterschied genügt, um eine Signatur Byte für Byte zu erraten.
Das Zeitfenster des Zeitstempels
Weisen Sie alles zurück, dessen Zeitstempel mehr als fünf Minuten von Ihrer eigenen Uhr abweicht, in beide Richtungen. Der Zeitstempel steckt genau deshalb in der signierten Zeichenkette, damit er nicht verändert werden kann, ohne die Signatur zu brechen; erst das Zeitfenster macht daraus einen Schutz. Ohne es bleibt eine einmal abgefangene Anfrage für immer gültig und kann jederzeit erneut eingespielt werden — die Signatur allein läuft nie ab. Halten Sie die Uhr Ihres Servers per NTP nach, sonst beginnt diese Prüfung, gute Zustellungen abzulehnen.
Duplikate
Dasselbe Ereignis kann mehr als einmal ankommen. Das ist kein Bug: Wir wiederholen, bis Sie mit 2xx antworten, und eine erfolgreiche Zustellung, deren Antwort uns nie erreicht hat, wird erneut gesendet. Erfassen Sie X-Paysell-Event-Id (der Wert kommt auch als event_id im Body) und sorgen Sie dafür, dass die zweite Ankunft nichts bewirkt.
Wiederholungen
Der erste Versuch geht hinaus, sobald die Zahlung gutgeschrieben ist. Schlägt er fehl — Timeout, abgelehnte Verbindung, TLS-Fehler, eine Weiterleitung oder ein beliebiger Status außerhalb von 2xx —, wiederholen wir nach einem festen Zeitplan:
1 Min → 5 Min → 15 Min → 1 Std → 6 Std → 24 StdInsgesamt sieben Versuche, verteilt über rund 31 Stunden. Die frühen liegen dicht beieinander, weil die übliche Ursache ein Empfänger ist, der gerade neu gestartet hat und schon wieder läuft; die späten sind spärlich, weil es niemandem hilft, einen seit einem Tag ausgefallenen Server zu bombardieren.
Nach dem letzten Versuch wird die Zustellung als dropped markiert und wir hören von selbst auf. Verloren ist sie nicht: Die Zahlungszeile in Ihrem Kundenbereich zeigt den Zustand, die Anzahl der Versuche und die Fehlerklasse, mit einer Schaltfläche Erneut senden, die einen frischen Durchlauf aller sieben Versuche startet. Ihr anderer Weg ist GET /api/merchant/v1/invoices/{invoice_id} — die Rechnung kennt ihren eigenen Status immer.
Wie eine Webhook-URL aussehen muss
Die URL wird beim Speichern geprüft und erneut vor jeder einzelnen Zustellung. Eine URL, die die Prüfung nicht besteht, wird beim Speichern mit 422 und code: "webhook_url_rejected" beantwortet und markiert die Zustellung als failed — ohne Wiederholungen —, falls sie später zu scheitern beginnt. Die Regeln:
- Nur `https://`, und Port 443. Ein Webhook trägt Zahlungsdaten; in reinem http sind sie für jeden auf dem Weg lesbar.
- Ein Domainname, keine IP-Adresse. Sie brauchen ohnehin ein Zertifikat, und für nackte IPs werden keine ausgestellt.
- Kein `localhost`, und kein Name auf
.local,.internal,.corp,.lanoder.test— unsere Server erreichen Ihr Netz nicht, und ein Name, der innerhalb unseres Netzes auflöst, ist genau das, was wir nicht aufrufen dürfen. - Keine Zugangsdaten in der URL (
https://user:pass@…). Legen Sie Ihr eigenes Token in den Pfad oder einen Query-Parameter, falls Sie eines brauchen. - Jede Adresse, auf die der Name auflöst, muss öffentlich sein — A und AAAA gleichermaßen. Private, Loopback-, Link-Local- und CGNAT-Bereiche werden abgelehnt, und die Prüfung wird vor jeder Zustellung wiederholt; den Eintrag später auf
127.0.0.1zu zeigen, funktioniert also ebenso wenig. - Weiterleitungen sind ein Fehlschlag, kein Zwischenschritt. Wir folgen ihnen nicht: Geprüft wurde die Adresse, die Sie uns genannt haben, und nicht die in einem
Location-Header.
Schnell antworten
Jedes 2xx genügt, innerhalb von zehn Sekunden — das ist unser gesamtes Timeout, den Verbindungsaufbau eingeschlossen. Antworten Sie zuerst, erledigen Sie die langsame Arbeit danach; ein Endpunkt, der vor der Antwort auf seine eigene Datenbank wartet, wird früher oder später als Timeout erfasst und erneut angesprochen, und Sie verarbeiten dasselbe Ereignis zweimal. Alles andere — ein 4xx, ein 5xx, eine Weiterleitung, ein Hänger — zählt als fehlgeschlagener Versuch und geht zurück in den obigen Zeitplan.
Zustellung, ehrlich gesagt
Garantiert ist der Zustellmechanismus: sieben Versuche über rund 31 Stunden, ein manuelles erneutes Senden aus Ihrem Kundenbereich und ein Rechnungs-Endpunkt, der den echten Status immer kennt. Bauen Sie den Ablauf so, dass ein Webhook, der nie ankommt, Sie nichts kostet — lesen Sie die Rechnung auf Ihrer Dankesseite, oder gleichen Sie einmal pro Stunde die offenen Rechnungen ab. Webhooks sind der schnelle Weg, nicht der einzige.
A complete receiver#
Signature, deduplication and a fast answer, end to end.
The snippets above verify one signature. This is the whole endpoint: raw body, signature check, deduplication by event_id, a fast 2xx, and the one condition that is allowed to mark an order paid.
Node.js with Express. express.raw is the part people get wrong: express.json() hands you a parsed object, and bytes you re-serialise from it are not the bytes we signed.
const express = require("express")
const crypto = require("node:crypto")
const app = express()
const SECRET = process.env.PAYSELL_WEBHOOK_SECRET
function isOurs(body, signature, timestamp) {
const sentAt = Number(timestamp)
if (!Number.isFinite(sentAt)) return false
if (Math.abs(Date.now() / 1000 - sentAt) > 300) return false // ±5 minutes
const expected = "sha256=" + crypto
.createHmac("sha256", SECRET)
.update(timestamp + ".").update(body) // raw Buffer, not a parsed object
.digest("hex")
const a = Buffer.from(expected), b = Buffer.from(signature ?? "")
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
app.post(
"/paysell/webhook",
express.raw({ type: "application/json" }), // NOT express.json()
async (req, res) => {
const signature = req.get("X-Paysell-Signature")
const timestamp = req.get("X-Paysell-Timestamp")
if (!isOurs(req.body, signature, timestamp)) return res.sendStatus(401)
const event = JSON.parse(req.body.toString("utf8"))
// Answer first: 10 seconds is the whole timeout, connection included.
res.sendStatus(200)
// Deduplicate. In real code this is a unique column, not a Set.
if (await alreadyHandled(event.event_id)) return
await remember(event.event_id)
if (event.type !== "payment.credited") return
const { order_id, status, credited, asset, tx_hash } = event.data
// The only condition that may release the goods.
if (status !== "paid" && status !== "overpaid") return
await markOrderPaid(order_id, { credited, asset, tx_hash })
}
)Python with Flask. request.get_data() is the raw body; request.form and request.json are not.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["PAYSELL_WEBHOOK_SECRET"]
def is_ours(body: bytes, signature: str, timestamp: str) -> bool:
try:
sent_at = int(timestamp)
except (TypeError, ValueError):
return False
if abs(time.time() - sent_at) > 300: # ±5 minutes
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest("sha256=" + expected, signature)
@app.post("/paysell/webhook")
def paysell_webhook():
body = request.get_data() # raw bytes, unparsed
if not is_ours(body, request.headers.get("X-Paysell-Signature", ""),
request.headers.get("X-Paysell-Timestamp", "")):
return "", 401
event = json.loads(body)
# Deduplicate. In real code this is a unique column, not a set.
if already_handled(event["event_id"]):
return "", 200 # a repeat is still a success
remember(event["event_id"])
if event["type"] == "payment.credited":
data = event["data"]
# The only condition that may release the goods.
if data["status"] in ("paid", "overpaid"):
mark_order_paid(data["order_id"], data)
return "", 200 # 2xx within 10 secondsWhat the code is doing, and why
- Verify before anything acts on the body. An unsigned request that reaches your business logic is a paid order for whoever found your URL.
- Answer 2xx first, work afterwards. Ten seconds is the whole timeout, connection included. A handler that waits for its own database gets recorded as a timeout and retried, and you process the same event twice.
- Deduplicate on `event_id` in storage that survives a restart. The in-memory set in the examples keeps them short; a real one is a unique column in your database.
- Mark the order paid only on `status: paid` or `overpaid`.
underpaidmeans part of the money arrived and the invoice is still open, and a deposit in the wrong coin never makes an invoice paid either. - Answer 2xx to a duplicate too. A repeat that gets a 4xx looks like a failure to us and comes back again on the schedule.
payment.rejected arrives at the same endpoint. It means a deposit held for an additional check was declined and the money will not be credited: release nothing, and if the invoice was already paid by an earlier transfer, this event is about the extra deposit, not about that payment.
Statusreferenz#
Alle Rechnungs- und Zahlungsstatus, erklärt.
Rechnung
| Status | Bedeutung | Was zu tun ist |
|---|---|---|
| pending | Wartet auf Zahlung. | Bestellung offen halten. |
| paid | Vollständig bezahlt. | Ware freigeben. |
| overpaid | Es kam mehr an als verlangt. Der Überschuss wird Ihnen vollständig gutgeschrieben. | Ware freigeben; die Differenz auf Wunsch erstatten. |
| underpaid | Es kam weniger an als verlangt. Die Rechnung bleibt offen und behält ihre Adresse: Der Käufer kann an dieselbe Stelle nachzahlen, und paid_minor sagt, wie viel bereits eingegangen ist. Sie bleibt für den Rest ihrer Lebensdauer zahlbar, plus eine Kulanzfrist von 24 Stunden nach expires_at. | Auf die Nachzahlung warten oder sich mit dem Kunden einigen. Die Ware nicht freigeben — die Rechnung ist nicht bezahlt. |
| expired | Das Zeitfenster ist geschlossen, Kulanzfrist eingeschlossen. Kann trotzdem Geld tragen: Was angekommen ist, blieb auf Ihrem Guthaben, und paid_minor sagt, wie viel. | Eine neue Rechnung anbieten. Keine Zahlung an die alte Adresse akzeptieren: Sobald eine Rechnung abläuft, geht die Adresse zurück in den Pool, und eine sehr späte Überweisung ist ein Support-Fall statt einer automatischen Gutschrift. Prüfen Sie paid_minor, bevor Sie dem Kunden sagen, es sei nichts eingegangen. |
| cancelled | Von Ihnen storniert. Die Adresse geht zurück in den Pool. | Nichts. |
Zahlung
Sichtbar in Ihrem Kundenbereich; nützlich bei der Betreuung eines Kunden während der Zahlung.
| Status | Bedeutung |
|---|---|
| detected | On-Chain gesehen, wartet auf Bestätigungen. |
| confirmed | Das Netzwerk hat bestätigt. Als Nächstes die Gutschrift. |
| credited | Auf Ihrem Guthaben. Das ist der Moment, in dem der Webhook auslöst. |
| review | Für eine zusätzliche Prüfung zurückgehalten — zum Beispiel Coins, die an einer Adresse ohne offene Rechnung ankommen. |
| rejected | Nicht gutgeschrieben. Der Grund wird erfasst. |
Wenn eine Zahlung in `review` geht
Manche Eingänge werden für eine zusätzliche Prüfung zurückgehalten, statt sofort gutgeschrieben zu werden: eine ungewöhnlich große Summe, Coins, die an einer Adresse ohne offene Rechnung ankommen, oder zwei von uns abgefragte Blockchain-Quellen, die sich über das Geschehene uneinig sind. Nichts geht verloren — das Geld wartet auf eine Entscheidung, und der Webhook löst aus, sobald sie vorliegt, was Minuten oder Stunden später sein kann. Behandeln Sie einen ausbleibenden Rückruf bei einer als review angezeigten Zahlung als normal und nicht als Fehler. Wenn es für eine Bestellung wichtig ist, fragen Sie den Support und nennen Sie den tx_hash.
Beträge#
Normale Einheiten hinaus, kleinste Einheiten zurück.
Beträge werden in den normalen Einheiten der Coin gesendet, als String — "1.5" ist eineinhalb. Keine JSON-Zahl und nicht die kleinste Einheit.
| Asset | Dezimalstellen | Sie senden | amount_minor in der Antwort |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "1.5" | "1500000" |
Ein String statt einer Zahl, weil JSON-Zahlen IEEE-754-Doubles sind und eine große Summe in Nanoton darin nicht mehr exakt darstellbar ist. Mehr Nachkommastellen, als die Coin hat, ergibt 422 — und niemals ein stilles Runden Ihres Geldes. Webhooks laufen andersherum: dort sind amount, fee und credited ganze Zahlen in der kleinsten Einheit, denn diese Seite liest Code, kein Mensch.
// 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. 1500000nLimits#
Minima, Maxima und Ratenlimits.
| Limit | Wert | Bei Überschreitung |
|---|---|---|
| Mindestrechnung | 0.1 TON · 3 USDT | 422 |
| Höchstrechnung | 7000 TON · 10000 USDT | 422 |
| Rechnungen pro Stunde, pro Shop | 60 | 429 |
| Gleichzeitig offene Rechnungen | 20, wächst mit jeder bezahlten Rechnung, bis zu 200 | 429 |
| Lebensdauer der Rechnung | 1 Minute – 24 Stunden (Standard 2 Stunden) | 422 |
| API-Anfragen pro Schlüssel | 120 pro Minute | 429 + Retry-After |
Das Minimum ist keine Bürokratie. Unsere Gebühr ist prozentual, aber das Einziehen einer Zahlung kostet einen festen Betrag: USDT von einer Empfangsadresse abzuziehen bedeutet, sie zuerst mit Gas zu versorgen — aus unserer eigenen Tasche. Unter ein paar Dollar deckt die Gebühr die Bearbeitung nicht, und eine solche Zahlung anzunehmen würde bedeuten, Ihnen Geld gutzuschreiben, dessen Bewegung unwirtschaftlich ist.
Das Maximum richtet sich nicht gegen große Händler — es ist eine Falle für den Einheitenfehler. Schicken Sie "5000000", wo Sie "5" meinten, entstünde sonst eine Rechnung über fünf Millionen Dollar: Der Käufer sieht eine absurde Summe und geht. Eine echte Bestellung stößt nie an diese Decke, ein Fehler immer. Beide Obergrenzen sind Einstellungen (invoice_max_ton, invoice_max_usdt) und können für Ihren Shop angehoben werden — fragen Sie einfach nach.
Das Stundenlimit und das Limit für offene Rechnungen schützen beide den Adresspool. Jede offene Rechnung belegt eine Empfangsadresse, und eine außer Kontrolle geratene Schleife auf einer Seite würde sonst den Pool für alle anderen aufbrauchen. Ein neuer Shop darf 20 Rechnungen gleichzeitig offen halten; das Kontingent wächst um eins für jede Rechnung, die er tatsächlich eingezogen hat, bis zu einer Obergrenze von 200. underpaid zählt als offen — die Rechnung belegt weiterhin ihre Adresse und wartet auf den Rest. Das Stornieren einer aufgegebenen Rechnung gibt ihre Adresse sofort frei. Wiederholungen mit demselben idempotency_key zählen nicht gegen das Stundenlimit.
Das Anfragelimit beträgt 120 pro Minute pro API-Schlüssel — zwei Aufrufe pro Sekunde, deutlich über jedem echten Bestellaufkommen. Ein 429 trägt einen Retry-After-Header in Sekunden: Warten Sie diese Zeit ab, statt in einer engen Schleife zu wiederholen, was das Zeitfenster nur weiter hinausschiebt.
Fehler#
Die Statuscodes, die Sie tatsächlich sehen werden.
Fehler kommen als JSON zurück, in zwei Formen. Alles, was wir oder der Verarbeitungskern entscheiden, legt unter detail ein Paar {code, message} ab. Ein Request-Body, der die Validierung nicht besteht, legt dort stattdessen eine Liste von Feldfehlern ab. Prüfen Sie, welche der beiden Formen Sie erhalten haben, bevor Sie detail.code lesen — und verzweigen Sie über `code`, nie über `message`: Die Formulierung kann sich jederzeit ändern, der Code nicht.
{
"detail": {
"code": "invalid_input",
"message": "invoice amount below the minimum: 0.010000 USDT_TON, minimum 3.000000 USDT_TON"
}
}{
"detail": [
{
"type": "string_type",
"loc": ["body", "amount"],
"msg": "Input should be a valid string",
"input": 5
}
]
}| Status | Wann | Was zu tun ist |
|---|---|---|
| 401 | Schlüssel fehlt, ist falsch oder widerrufen. | Header prüfen. Schlüssel neu ausstellen, falls widerrufen. |
| 404 | Diese Rechnung gibt es nicht, oder sie gehört einem anderen Shop. | ID prüfen. Beide Fälle werden mit Absicht gleich beantwortet, damit eine ID nicht abgetastet werden kann. |
| 409 | Die Rechnung ist in einem Zustand, der dies verbietet. | Zuerst ihren aktuellen Status lesen. |
| 422 | Die Anfrage ist fehlerhaft oder der Betrag liegt außerhalb der Rechnungsgrenzen. | Die Meldung nennt sowohl den gesendeten Wert als auch das Limit. |
| 429 | Zu viele Rechnungen in dieser Stunde, zu viele gleichzeitig offen oder zu viele Anfragen. | Retry-After abwarten und dann erneut versuchen. |
| 502 | Wir konnten den Verarbeitungskern nicht erreichen. | Mit demselben Idempotenzschlüssel erneut versuchen. |
Codes
Die von uns entschiedene Form ist {"detail": {"code": …, "message": …}}. Dies sind die Codes, die die Merchant-API zurückgibt.
| Code | Status | Bedeutung |
|---|---|---|
| invalid_api_key | 401 | Der Schlüssel fehlt, ist fehlerhaft, unbekannt oder widerrufen. Alle vier Fälle werden gleich beantwortet, sodass ein Schlüssel nicht abgetastet werden kann. |
| not_found | 404 | Dieses Objekt gibt es nicht, oder es gehört einem anderen Shop. |
| invalid_input | 422 | Die Anfrage hat die Validierung im Kern nicht bestanden — ein falscher Betrag, zu viele Nachkommastellen, ein Betrag außerhalb der Rechnungsgrenzen. |
| conflict | 409 | Die Aktion widerspricht dem aktuellen Zustand, etwa das Stornieren einer Rechnung, die nicht mehr offen ist. |
| too_many_requests | 429 | Ein Ratenlimit: Rechnungen pro Stunde, offene Rechnungen oder Anfragen pro Minute. Retry-After sagt, wie lange zu warten ist. |
| cbc_unreachable | 502 | Wir konnten den Verarbeitungskern nicht erreichen. Versuchen Sie es mit demselben idempotency_key erneut. |
| webhook_url_rejected | 422 | Nur beim Speichern eines Schlüssels: Die Webhook-URL hat die obigen Prüfungen nicht bestanden. detail.reason nennt die betroffene Regel — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials und so weiter. |
Ein 502 bedeutet nicht, dass die Rechnung nicht erstellt wurde — die Anfrage könnte durchgegangen sein, während die Antwort auf dem Rückweg verloren ging. Versuchen Sie es mit demselben idempotency_key erneut, und Sie erhalten entweder die bestehende Rechnung oder eine neue, nie zwei.
Checkout: what the buyer sees#
The hosted payment page, and when to build your own.
payment_url points at https://paysell.me/pay/{invoice_id}. One page, no account, no login, mobile first, and nothing for you to build.
On the page
- Your shop's name, the amount and the coin, large, with the invoice's description underneath.
- A countdown to
expires_at— plus the 24-hour grace period when the invoice isunderpaid. - The invoice's short id with a copy button, so a buyer can quote it to your support.
- Wallet buttons: Tonkeeper and MyTonWallet open with the address and the amount already filled in. Other reveals a QR code and the address with a copy button.
- A warning that only this invoice's coin, on the TON network, may be sent — anything else is lost.
How the page reacts
| Invoice | What the buyer sees |
|---|---|
| pending | “Waiting for payment”, with the wallet choices and the countdown. The page re-reads the invoice every five seconds. |
| underpaid | “Received X of Y”, the exact remainder still owed, and the same address to send it to. The wallet link is prefilled with what is missing, not with the original total — otherwise the buyer would pay twice. |
| paid · overpaid | “Payment received”, and a button back to your shop if the shop has a URL. |
| expired | “Payment window closed”. If money did arrive, the amount is named with a note to contact you — silence here would send the buyer looking for their coins. |
| cancelled | “Payment cancelled”, with a link back to your shop. |
If you build your own
You gain your own branding and take on all of the above: the exact address string, the right coin, the countdown with its grace period, the underpayment case, and polling. GET /api/merchant/v1/public/invoices/{invoice_id} is the same unauthenticated read the hosted page uses — rate limited per IP, so poll it no more often than every few seconds. Print the address exactly as returned.
Typical integration mistakes#
The handful that account for most broken integrations.
None of these are exotic. Every one of them has cost somebody a day.
Sending the amount as a number
{"amount": 5}is a422. It has to be the string"5": JSON numbers are IEEE-754 doubles, and a large sum in nanotons stops being exactly representable in one.Sending the smallest unit
"5000000"for 5 USDT is a mistake in units, and the upper invoice limit exists to catch it. Smallest units are what comes back in webhooks, not what goes out in requests.Treating the webhook's arrival as payment
Read
data.status.underpaidis not paid, and a deposit in a coin the invoice did not ask for never makes it paid either. Release the goods onpaidoroverpaid, on nothing else.Not checking the timestamp
A signature on its own never expires. Without the ±5 minute window on
X-Paysell-Timestamp, a delivery captured once can be replayed at any time and will still verify.Verifying the signature over re-serialised JSON
Parse the body and serialise it again and the bytes change — key order, spacing — and the HMAC no longer matches. Sign the raw bytes exactly as received.
No deduplication
The same
event_idwill arrive twice sooner or later: we retry until you answer 2xx, and a response lost on the way back looks like a failure from here. The second arrival must do nothing.Using the
Idempotency-KeyheaderThis API reads
idempotency_keyfrom the request body; the header is not read at all. A retry without the field opens a second invoice for the same order.Assuming
detailis always an objectIt is
{code, message}for anything we or the core decide, and a list of field errors when the body itself fails validation. Check which one you got before readingdetail.code.
Erstattungen#
So erstatten Sie einem Kunden Geld.
Erstattungen laufen über den Support, nicht über einen API-Aufruf. Eine Erstattung ist eine neue Überweisung an eine Adresse, die ein Mensch angegeben hat, und ein Zahlungsdienstleister, der auf einen API-Aufruf hin automatisch Geld zurückschickt, ist ein Zahlungsdienstleister, den man dazu bringen kann, Geld an die Adresse eines Angreifers zu schicken. Deshalb ist das bewusst manuell.
Um einem Käufer Geld zu erstatten, eröffnen Sie aus Ihrem Kundenbereich ein Support-Ticket mit der invoice_id oder dem tx_hash, dem Betrag und der Zieladresse. Ein Operator prüft die Zahlung, bucht das Geld aus Ihrem Guthaben aus und antwortet im selben Ticket. Rechnen Sie mit einem Arbeitstag, nicht mit einer Minute.
Zwei Konsequenzen, um die herum man planen sollte. Eine Überzahlung wird Ihnen vollständig gutgeschrieben — wir behalten nichts davon ein —, die Differenz an einen Käufer zurückzugeben, der zu viel gesendet hat, ist also Ihre Entscheidung und läuft über denselben Weg. Und eine unterbezahlte Rechnung ist kein Erstattungsfall, solange sie noch offen ist: Das Geld liegt auf Ihrem Guthaben, die Adresse wird weiterhin beobachtet, und der Käufer kann einfach nachzahlen. Erst nach der Kulanzfrist, wenn die Rechnung mit Geld darauf auf expired geht, ist eine Entscheidung zu treffen.
Testen#
So testen Sie Ihre Integration vor dem Start.
Die Schlüssel hier sind live: Jeder ausgestellte Schlüssel ist ein sk_live_-Schlüssel gegen den Produktivkern und das TON-Mainnet. Eine separate Testumgebung gibt es nicht, und das hat einen Vorteil: Sie durchlaufen genau den Weg, den Ihre echten Bestellungen nehmen werden.
Testen Sie also so, wie Sie alles testen würden, was echtes Geld berührt: mit kleinen Beträgen. Legen Sie eine Rechnung über das Minimum an (0.1 TON oder 3 USDT), bezahlen Sie sie aus Ihrer eigenen Wallet und beobachten Sie den gesamten Weg — die Zahlungsseite, den Webhook, die Signaturprüfung, das Umspringen Ihrer Bestellung auf bezahlt. Die Gebühr fällt an, und die Coins bewegen sich wirklich.
Die Teile, die Sie ohne Ausgaben durchspielen können: eine Rechnung anlegen und lesen, eine stornieren, das 422 bei einem fehlerhaften Betrag, das 401 bei einem falschen Schlüssel und Ihre eigene Signaturprüfung — signieren Sie einen Beispiel-Body mit Ihrem Secret und geben Sie ihn Ihrem eigenen Handler. Was wirklich eine echte Zahlung erfordert, ist nur der letzte Schritt: ein tatsächlicher payment.credited-Webhook.
Planen Sie die Integration so, dass sie nicht von einer Sandbox oder einer simulierten Zahlung abhängt: Der Live-Weg lässt sich schneller — und wahrheitsgetreuer — überprüfen.
For AI agents and LLMs#
Machine-readable copies of this page, and a prompt to start from.
Everything on this page also exists in a form a model can read directly. Point your assistant at one of these instead of pasting screenshots of documentation into a chat.
The three files
| File | What it is | Use it for |
|---|---|---|
| /llms-full.txt | The whole documentation as one markdown file: endpoints, fields, statuses, limits, errors, webhooks with working verification code, the fee, the checkout page, the checklist. | Pasting into a model's context, or letting an agent fetch it. Start here. |
| /llms.txt | A short index in the llms.txt format: what Paysell is, the five rules that decide whether an integration works, and links to everything else. | Letting an agent discover the rest on its own. |
| /openapi.json | OpenAPI 3.1, generated from the running application's own models, both webhook events included. | Generating a client, or loading into anything that speaks OpenAPI. |
A prompt to start from
Copy this, replace the stack, and hand it to your assistant. It names the four things that go wrong most often, so the answer does not have to be corrected afterwards.
Read https://paysell.me/llms-full.txt and implement Paysell payments in my <stack>:
create invoices (POST /api/merchant/v1/invoices, Bearer sk_live_ key, amount as a
decimal string in normal units), redirect the buyer to payment_url, verify webhook
signatures (HMAC-SHA256 over "{timestamp}.{raw_body}", header X-Paysell-Signature,
reject anything whose X-Paysell-Timestamp is more than 300 seconds off), deduplicate
by event_id, answer 2xx within 10 seconds, and mark orders paid only on a
payment.credited event whose data.status is "paid" or "overpaid".Feeding it to a specific tool
- Agents with web access — Claude Code, Cursor, Windsurf and the like: give them the
/llms-full.txtlink. One fetch, no setup. - A chat window — ChatGPT, Claude, Gemini: paste the contents of
/llms-full.txtinto the conversation or attach it as a file. It is written to fit in one message. - OpenAPI tooling — client generators, Postman, an agent's tool schema: point it at
https://paysell.me/openapi.json. Itsserversentry already carries the production base URL, so generated calls go to the right place.
Checkliste vor dem Go-Live#
Zehn Punkte vor dem Start.
- Der Schlüssel ist nur serverseitig, nie in Browser-JavaScript.
- Die Webhook-Signatur wird gegen
"{timestamp}.{raw_body}"geprüft, in konstanter Zeit. - Zustellungen, die älter als fünf Minuten sind, werden abgelehnt, und die Uhr des Servers läuft auf NTP.
- Eine wiederholte
X-Paysell-Event-Idbewirkt beim zweiten Mal nichts. - Der Webhook antwortet innerhalb von zehn Sekunden mit 2xx; langsame Arbeit folgt danach.
- Die Webhook-URL ist eine https://-Domain auf Port 443, ohne Weiterleitung davor.
- Ein ausgebliebener Webhook ist verkraftbar: Der Rechnungs-Endpunkt wird auf der Dankesseite oder bei einem Abgleichlauf gelesen.
idempotency_keywird einmal pro Bestellung generiert und bei Wiederholungen wiederverwendet.- Beträge gehen als Strings in normalen Einheiten hinaus; Webhook-Zahlen werden als kleinste Einheiten gelesen.
- Die Adresse wird exakt wie zurückgegeben angezeigt, unverändert.
overpaidundunderpaidwerden behandelt, nicht nurpaid;expiredkann weiterhinpaid_minortragen.- Ware wird bei
status: paidoderoverpaidfreigegeben, nie beim bloßen Eintreffen des Rückrufs. 429wird durch Abwarten vonRetry-Afterbehandelt, nicht durch sofortiges Wiederholen.- Guthaben werden bei uns gelesen, nicht separat als Wahrheit geführt.
Etwas unklar?
Wenn diese Seite Ihre Frage nicht beantwortet hat, ist das eine Lücke in der Dokumentation, die es sich zu melden lohnt. Schreiben Sie uns aus Ihrem Kundenbereich, und wir korrigieren die Seite, nicht nur die Antwort.