Modtag kryptobetalinger
Paysell afregner TON og USDT på TON-netværket. Du opretter en faktura, vi giver dig et link, og du modtager et signeret callback, så snart pengene er bekræftet on-chain og krediteret din saldo.
Oversigt#
Hvad Paysell gør, og hvad den ikke gør.
Paysell er en betalingsudbyder, ikke en tegnebog. Du håndterer aldrig private nøgler, overvåger ikke blockchainen, og bestemmer ikke, hvornår en transaktion er endelig — det tager vi os af.
Hver faktura får sin egen modtageradresse. Når en køber betaler den, venter vi på, at netværket bekræfter overførslen, trækker vores gebyr fra og krediterer resten til din saldo. Du hæver til enhver adresse, du ønsker.
Sådan fungerer en betaling#
Seks trin, de fleste af dem vores.
Seks trin, de fleste af dem vores:
- 1
Din kunde klikker på betal
Din server kalder vores API med beløbet og din egen ordrereference.
- 2
Vi udleverer en adresse
En frisk modtageradresse tages fra en forudgenereret pulje og knyttes til denne faktura. Én adresse tilhører præcis én åben faktura, og sådan matches en betaling til den.
- 3
Kunden sender mønterne
De scanner QR-koden eller kopierer adressen. Send dem til den
payment_url, vi returnerer, og siden klarer alt for dig — beløb, adresse, QR, nedtælling, live status. - 4
Vi registrerer overførslen
To uafhængige kilder til blockchain-data forespørges, og deres svar sammenlignes. Hvis de er uenige, stopper vi i stedet for at vælge det mest bekvemme svar.
- 5
Vi venter på endelighed
Optagelse i masterchain plus tre blokke oveni. Cirka femten sekunder — en betaling, der ser afregnet ud og derefter forsvinder, ville være dit tab, så vi tager ikke den risiko.
- 6
Krediteret, og du får besked
Gebyret trækkes fra, resten lander på din saldo, og en signeret webhook går til din server med dit
order_id.
Fra betaling til callback: cirka et minut — omkring femten sekunder med netværksbekræftelser, resten er vores gennemgang af overvågede adresser.
Hvor pengene går hen#
Gebyret, og hvad det beregnes af.
Gebyret er 0,2 %, fastsat for din butik på registreringstidspunktet. Hvis standardsatsen ændres senere, ændres din ikke — den er skrevet ind i hver faktura som et tal, ikke som en reference til en indstilling.
Gebyret tages fra det, der rent faktisk ankommer, ikke fra det, fakturaen bad om. Fakturér 5 USDT og modtag 20, så beregnes gebyret af 20. Betales der for lidt, beregnes det af det, der kom ind.
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 USDTOverbetaling krediteres fuldt ud — vi beholder ikke differencen. Underbetaling holder fakturaen åben, så køberen kan efterbetale til samme adresse.
Hurtig start#
Fem minutter til din første faktura.
Fem trin. To er klik i dit kontoområde, ét er en enkelt anmodning fra din server, og de sidste to sker af sig selv.
- 1
Opret en butik
I dit kontoområde. Den begynder at modtage betalinger med det samme — uden at vente på godkendelse. Verifikation foregår stille i baggrunden og begrænser kun udbetalinger, ikke indgående betalinger.
- 2
Udsted en API-nøgle
Din butik → API-nøgler → Ny nøgle. Nøglen og webhook-hemmeligheden vises kun én gang og aldrig igen. Opbevar dem, som du ville opbevare en databaseadgangskode, og send dem aldrig til en browser.
- 3
Opret en faktura
Én anmodning fra din server, ét link tilbage. De fire kodeeksempler nedenfor sender alle nøjagtig det samme.
- 4
Send køberen til
payment_urlDet er hele betalingssiden — beløb, adresse, QR-kode, nedtælling, status i realtid — og der er intet at bygge. Se Betalingssiden for, hvad køberen rent faktisk ser.
- 5
Vent på webhooken
Når pengene er bekræftet på kæden og krediteret, laver vi en POST med en signeret
payment.credited-hændelse til din server. Verificér signaturen, og markér derefter ordren som betalt — men kun nårdata.statuserpaidelleroverpaid. Se 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"
}'Omdiriger køberen til payment_url i svaret. Du er færdig — resten ankommer som en 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.
Godkendelse#
Din API-nøgle, og hvordan den bruges.
Hver anmodning bærer din nøgle i Authorization-headeren:
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxAHver nøgle, der udstedes her, starter med sk_live_. Præfikset sk_test_ findes kun i en installation, der peger på testnetværket, og en sådan installation tilbydes ikke — se Test. Vi gemmer en envejs-hash, ikke selve nøglen, så ingen, os inklusive, kan vise den til dig igen. Mistet den? Udsted en ny, og tilbagekald den gamle.
Butikken udledes af nøglen, derfor tager ingen anmodning nogensinde et butiks-id. En nøgle kan kun handle på sin egen butik.
Stien bærer en version: /api/merchant/v1/…. Inden for en version tilføjer vi kun felter — intet omdøbes, og intet skifter stille betydning. En ændring, der ville bryde din kode, får et nyt præfiks, /v2, og /v1 kører videre i en annonceret periode.
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.
Opret en faktura#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoicesAnmodningstekst
| Felt | Type | Påkrævet | Beskrivelse |
|---|---|---|---|
| asset | string | ja | Enten TON eller USDT_TON. |
| amount | string | ja | Møntens normale enheder, som streng: "5" er 5 USDT. Ikke flere decimaler end mønten har. Se Beløb. |
| order_id | string | nej | Din egen reference, op til 200 tegn. Kommer tilbage i hver webhook — sådan matcher du en betaling med en ordre. |
| description | string | nej | Op til 1000 tegn. Vises til køberen på betalingssiden. |
| ttl_minutes | number | nej | Hvor længe fakturaen forbliver betalbar, i minutter. 1–1440; udelad den, så gælder standardværdien — 2 timer i dag. |
| idempotency_key | string | nej | Op til 200 tegn. Send samme værdi ved gentagelse, og du får den samme faktura tilbage i stedet for en ny. Et felt i body'en, ikke headeren Idempotency-Key — den header læses ikke her. |
Svar · 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"
}Sådan bruger du det i din ordre
| Felt | Hvad du skal gøre med det |
|---|---|
| invoice_id | Gem den sammen med din ordre. Det er det, der identificerer betalingen alle andre steder. |
| payment_url | Omdiriger køberen hertil. Der er ikke andet at bygge. |
| address | Kun hvis du bygger din egen betalingsside. Vis den nøjagtigt som modtaget — se advarslen nedenfor. |
| amount | Beløbet i normale enheder, præcis som du sendte det. Vis dette. |
| amount_minor | Samme beløb som heltal i mindste enhed. Regn med dette. |
| expires_at | Vis en nedtælling. Når den er udløbet, holder adressen op med at blive overvåget for denne faktura. |
| status | Her er den altid pending. Reelle ændringer ankommer via webhook. |
UQ… på mainnet, 0Q… på testnet). At konvertere den, pynte på den eller udskifte den med en anden kodning af samme adresse vil få mønter sendt til en endnu ikke udrullet tegnebog til at hoppe tilbage til afsenderen.Læs en faktura#
GET /api/merchant/v1/invoices/{invoice_id}
/api/merchant/v1/invoices/{invoice_id}Samme form som ovenfor, hvor status, paid og paid_minor afspejler nutiden: paid er, hvor meget der er kommet ind i normale enheder, paid_minor det samme som et heltal i mindste enhed. Nyttig som en reserveløsning, når en webhook blev overset, eller på en kvitteringsside.
Forespørg højst hvert par sekunder, og betragt webhooks som den primære kanal. Fakturaer, der tilhører en anden butik, svarer 404 — ikke 403, så et id ikke kan afsøges for eksistens.
Annuller en faktura#
POST /api/merchant/v1/invoices/{invoice_id}/cancel
/api/merchant/v1/invoices/{invoice_id}/cancelLukker en faktura, der stadig er åben — pending eller underpaid — og frigiver dens adresse. Brug det, når kunden forlader betalingssiden: adresser er en begrænset ressource, og at returnere dem holder puljen sund.
En faktura, der ikke længere er åben, svarer 409. At annullere en underpaid faktura sender ikke mønter tilbage til nogen: penge, der allerede er krediteret, bliver på din saldo, og det eneste, der lukkes, er muligheden for at efterbetale.
Webhooks#
Hvad der ankommer, og hvordan man verificerer det.
Angiv en webhook-URL, når du opretter en nøgle. Vi laver en POST dertil, når en betaling er krediteret — og når en indbetaling, der blev holdt tilbage til et ekstra tjek, bliver afvist. Hver levering er signeret, og vi bliver ved med at prøve igen i omkring halvandet døgn, indtil du svarer 2xx. Frigiv varen ved status: paid eller overpaid — ikke fordi kaldet blot er kommet frem.
Hændelser
| Hændelse | Hvornår | Hvad kroppen indeholder |
|---|---|---|
| payment.credited | Overførslen er bekræftet på kæden, vores gebyr er trukket, og resten står på din saldo. | Felterne nedenfor. |
| payment.rejected | En indbetaling, der blev holdt tilbage til et ekstra tjek (se Statusreference), er afvist. Pengene når ikke din saldo. | invoice_id, order_id, asset, amount, tx_hash og reason. Frigiv ikke varen; hvis fakturaen allerede var paid fra en tidligere overførsel, handler denne hændelse om den ekstra indbetaling og ikke om den betaling. |
Hvad der ankommer
{
"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"
}
}Feltmapping
| Felt | Betydning |
|---|---|
| event_id | Unik for hver hændelse; findes også i headeren X-Paysell-Event-Id. Gem den, og ignorer gentagelser — se nedenfor. |
| data.order_id | Din reference. Slå din ordre op med den. |
| data.amount | Hvad køberen sendte i denne overførsel, i mindste enhed — i modsætning til API'et, der tager normale enheder. |
| data.fee | Hvad vi tog, i mindste enhed. |
| data.credited | Hvad der landede på din saldo: amount − fee, i mindste enhed. |
| data.paid_minor | Samlet modtaget på denne faktura indtil nu, i mindste enhed. Det felt, der betyder noget ved underpaid: statussen siger, at der kom mindre ind, dette siger hvor meget mindre. |
| data.asset | Den mønt, der faktisk kom ind. Ikke nødvendigvis den mønt, fakturaen bad om. |
| data.asset_mismatch | Findes, og er true, kun når den indkomne mønt ikke er fakturaens mønt. Pengene krediteres dig, men fakturaen forbliver ubetalt, og status bliver aldrig paid. |
| data.invoice_asset | Følger med asset_mismatch: den mønt, fakturaen faktisk beder om. |
| data.status | Fakturaens status nu: pending, underpaid, paid, overpaid eller expired. Sammenlign med det, du forventede. |
| data.tx_hash | Transaktionen on-chain, til dine optegnelser og support. |
Headere på hver levering
X-Paysell-Event: payment.credited
X-Paysell-Event-Id: 99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp: 1789000000
X-Paysell-Signature: sha256=6f1c0e6a…| Header | Betydning |
|---|---|
| X-Paysell-Event | Hændelsestypen: payment.credited eller payment.rejected. |
| X-Paysell-Event-Id | Unik for hver hændelse. Det er den værdi, du skal deduplikere på. |
| X-Paysell-Timestamp | Hvornår vi signerede, i unix-sekunder. Den indgår i den signerede streng. |
| X-Paysell-Signature | sha256= efterfulgt af HMAC'en i hex. Se nedenfor. |
Verificering af signaturen
Hver anmodning signeres med webhook-hemmeligheden, der blev vist én gang, da du oprettede nøglen. Signaturen er HMAC-SHA256(secret, "{timestamp}.{raw_body}") — tidsstemplet fra X-Paysell-Timestamp, et bogstaveligt punktum og derefter body-bytene. Tjek den, før du handler: uden det kan enhver, der lærer din URL at kende, forsyne dig med en betalt ordre.
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)
}Signér de rå body-bytes, nøjagtigt som modtaget. Hvis du parser JSON'en og serialiserer den igen, ændres bytene — nøglerækkefølge, mellemrum — og signaturen vil ikke stemme. Sammenlign i konstant tid (hmac.compare_digest, crypto.timingSafeEqual): et almindeligt == vender hurtigere tilbage ved en forkert første byte, og den forskel er nok til at gætte en signatur én byte ad gangen.
Tidsstempelvinduet
Afvis alt, hvis tidsstempel ligger mere end fem minutter fra dit eget ur, i begge retninger. Tidsstemplet ligger inde i den signerede streng netop for at det ikke kan redigeres uden at ødelægge signaturen; vinduet er det, der gør dette til beskyttelse. Uden det forbliver en anmodning, der er opsnappet én gang, gyldig for evigt og kan afspilles igen når som helst — signaturen alene udløber aldrig. Hold din servers ur på NTP, ellers begynder dette tjek at afvise gyldige leveringer.
Dubletter
Den samme hændelse kan ankomme mere end én gang. Det er ikke en fejl: vi prøver igen, indtil du svarer 2xx, og en levering, der lykkedes, men hvis svar aldrig nåede os, sendes igen. Registrer X-Paysell-Event-Id (den kommer også som event_id i body'en), og sørg for, at den anden ankomst ikke gør noget.
Gentagne forsøg
Det første forsøg sendes, så snart betalingen er krediteret. Hvis det mislykkes — timeout, afvist forbindelse, TLS-fejl, en omdirigering eller en hvilken som helst ikke-2xx-status — prøver vi igen efter en fast plan:
1 min → 5 min → 15 min → 1 t → 6 t → 24 tSyv forsøg i alt, fordelt over cirka 31 timer. De tidlige ligger tæt på hinanden, fordi den sædvanlige årsag er en modtager, der var ved at genstarte og allerede er tilbage; de sene ligger spredt, fordi det ikke gavner nogen at hamre løs på en server, der har været nede i et døgn.
Efter det sidste forsøg markeres leveringen som dropped, og vi stopper af os selv. Den er ikke tabt: betalingsrækken i dit kontoområde viser tilstanden, antallet af forsøg og fejlklassen, med en Send igen-knap, der starter et helt nyt forløb med alle syv forsøg. Din anden udvej er GET /api/merchant/v1/invoices/{invoice_id} — fakturaen kender altid sin egen status.
Hvordan en webhook-URL skal se ud
URL'en tjekkes, når du gemmer den, og igen før hver eneste levering. En URL, der ikke består tjekket, besvares med 422 og code: "webhook_url_rejected" ved lagringen, og markerer leveringen som failed — uden gentagne forsøg — hvis den begynder at fejle senere. Reglerne:
- Kun `https://`, og port 443. En webhook bærer betalingsoplysninger; over almindelig http kan de læses af alle på vejen.
- Et domænenavn, ikke en IP-adresse. Du skal alligevel bruge et certifikat, og der udstedes ikke certifikater til bare IP-adresser.
- Ingen `localhost`, og intet
.local-,.internal-,.corp-,.lan- eller.test-navn — vores servere kan ikke nå dit netværk, og et navn, der slår op inde i vores, er præcis det, vi ikke må kalde. - Ingen legitimationsoplysninger i URL'en (
https://user:pass@…). Læg dit eget token i stien eller i en query-parameter, hvis du har brug for et. - Alle adresser, navnet slår op til, skal være offentlige — både A og AAAA. Private adresser, loopback, link-local og CGNAT-intervaller afvises, og tjekket gentages før hver levering, så det virker heller ikke at pege posten mod
127.0.0.1senere. - Omdirigeringer er en fejl, ikke et hop. Vi følger dem ikke: den adresse, du gav os, er tjekket, og den i en
Location-header er ikke.
Svar hurtigt
Enhver 2xx duer, inden for ti sekunder — det er hele vores timeout, forbindelsen inklusive. Svar først, og lav det langsomme arbejde bagefter; et endpoint, der venter på sin egen database, før det svarer, ender med at blive registreret som en timeout og forsøgt igen, og så behandler du den samme hændelse to gange. Alt andet — en 4xx, en 5xx, en omdirigering, et hæng — tæller som et mislykket forsøg og går tilbage i planen ovenfor.
Levering, ærligt talt
Det, der er garanteret, er leveringsmekanismen: syv forsøg over cirka 31 timer, en manuel gensendelse fra dit kontoområde, og et faktura-endpoint, der altid kender den rigtige status. Byg dit flow, så en webhook, der aldrig ankommer, ikke koster dig noget — læs fakturaen på din kvitteringsside, eller afstem åbne fakturaer én gang i timen. Webhooks er den hurtige vej, ikke den eneste vej.
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.
Statusreference#
Alle faktura- og betalingsstatusser, forklaret.
Faktura
| Status | Betydning | Hvad du skal gøre |
|---|---|---|
| pending | Venter på betaling. | Hold ordren åben. |
| paid | Betalt fuldt ud. | Lever varerne. |
| overpaid | Der kom mere ind, end der blev bedt om. Overskuddet krediteres dig fuldt ud. | Lever varerne; refunder differencen, hvis du ønsker det. |
| underpaid | Der kom mindre ind, end der blev bedt om. Fakturaen forbliver åben og beholder sin adresse: køberen kan efterbetale til det samme sted, og paid_minor siger, hvor meget der allerede er kommet ind. Den kan betales resten af sin levetid plus 24 timers henstand efter expires_at. | Vent på efterbetalingen, eller find en løsning med kunden. Lever ikke varerne — fakturaen er ikke betalt. |
| expired | Vinduet lukkede, henstandsperioden inklusive. Kan stadig indeholde penge: hvad der kom ind, blev på din saldo, og paid_minor siger hvor meget. | Tilbyd en ny faktura. Accepter ikke betaling på den gamle adresse: når en faktura udløber, går adressen tilbage i puljen, og en meget sen overførsel er en supportsag frem for en automatisk kreditering. Tjek paid_minor, før du fortæller kunden, at der intet blev modtaget. |
| cancelled | Annulleret af dig. Adressen frigives tilbage til puljen. | Ingenting. |
Betaling
Synlig i dit kontoområde; nyttig når du støtter en kunde midt i en betaling.
| Status | Betydning |
|---|---|
| detected | Set on-chain, venter på bekræftelser. |
| confirmed | Netværket bekræftede det. Kreditering er næste. |
| credited | På din saldo. Dette er, når webhooken udløses. |
| review | Holdt tilbage til et ekstra tjek — for eksempel mønter, der ankommer til en adresse uden en åben faktura. |
| rejected | Ikke krediteret. Årsagen er registreret. |
Når en betaling går til `review`
Nogle indbetalinger holdes tilbage til et ekstra tjek i stedet for at blive krediteret med det samme: et usædvanligt stort beløb, mønter der ankommer til en adresse uden en åben faktura, eller de to blockchain-kilder, vi spørger, er uenige om, hvad der skete. Intet går tabt — pengene venter på en afgørelse, og webhooken udløses, så snart den foreligger, hvilket kan være minutter eller timer senere. Betragt et manglende kald på en betaling, der vises som review, som normalt frem for som en fejl. Hvis det har betydning for en ordre, så spørg support og oplys tx_hash.
Beløb#
Normale enheder ud, mindste enheder tilbage.
Send beløb i møntens normale enheder, som streng — "1.5" er halvanden. Ikke et JSON-tal og ikke den mindste enhed.
| Aktiv | Decimaler | Du sender | amount_minor i svaret |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "1.5" | "1500000" |
En streng og ikke et tal, fordi JSON-tal er IEEE-754-doubles, og et stort beløb i nanoton holder op med at kunne rummes præcist i et sådant. Flere decimaler end mønten har giver 422, aldrig en tavs afrunding af dine penge. Webhooks går den modsatte vej: der er amount, fee og credited heltal i mindste enhed, for den side læses af kode, ikke af et menneske.
// 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. 1500000nGrænser#
Minimum, maksimum og hastighedsgrænser.
| Grænse | Værdi | Ved overtrædelse |
|---|---|---|
| Minimumfaktura | 0.1 TON · 3 USDT | 422 |
| Maksimumfaktura | 7000 TON · 10000 USDT | 422 |
| Fakturaer pr. time, pr. butik | 60 | 429 |
| Åbne fakturaer ad gangen | 20, vokser med hver betalt faktura, op til 200 | 429 |
| Fakturaens levetid | 1 minut – 24 timer (standard 2 timer) | 422 |
| API-anmodninger pr. nøgle | 120 pr. minut | 429 + Retry-After |
Minimummet er ikke bureaukrati. Vores gebyr er en procentdel, men at opkræve en betaling koster et fast beløb: at flytte USDT ud af en modtageradresse betyder at finansiere den med gas først, fra vores egen lomme. Under et par dollars dækker gebyret ikke håndteringen, og at acceptere en sådan betaling ville betyde at kreditere dig penge, det ikke kan betale sig at flytte.
Maksimum handler ikke om store forhandlere — det er en fælde for en enhedsfejl. Send "5000000", hvor du mente "5", og ellers ville du få en faktura på fem millioner dollars: køberen ser et absurd beløb og går. En rigtig ordre rammer aldrig dette loft; en fejl rammer det altid. Begge lofter er indstillinger (invoice_max_ton, invoice_max_usdt) og kan hæves for din butik — bare spørg.
Timegrænsen og grænsen for åbne fakturaer beskytter begge adressepuljen. Hver åben faktura optager en modtageradresse, og en løbsk løkke på ét websted ville ellers dræne puljen for alle andre. En ny butik må have 20 fakturaer åbne ad gangen; kvoten vokser med én for hver faktura, den faktisk har fået betalt, op til et loft på 200. underpaid tæller som åben — den holder stadig sin adresse og venter på resten. Annullerer du en opgivet faktura, frigives dens adresse med det samme. Gentagelser med samme idempotency_key tæller ikke mod timegrænsen.
Anmodningsgrænsen er 120 pr. minut pr. API-nøgle — to kald i sekundet, et godt stykke over ethvert reelt ordreflow. En 429 bærer en Retry-After-header i sekunder: vent så længe frem for at prøve igen i en tæt løkke, hvilket kun skubber vinduet længere ud.
Fejl#
De statuskoder, du faktisk vil se.
Fejl returneres som JSON, i to former. Alt, hvad vi eller behandlingskernen afgør, lægger et {code, message}-par under detail. En anmodnings-body, der ikke består valideringen, lægger i stedet en liste af feltfejl der. Tjek, hvilken af dem du fik, før du læser detail.code — og forgren på `code`, aldrig på `message`: ordlyden kan ændre sig når som helst, koden gør ikke.
{
"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 | Hvornår | Hvad du skal gøre |
|---|---|---|
| 401 | Nøglen mangler, er forkert, eller er tilbagekaldt. | Tjek headeren. Genudsted nøglen, hvis den blev tilbagekaldt. |
| 404 | Ingen sådan faktura, eller den tilhører en anden butik. | Tjek id'et. De to tilfælde besvares ens med vilje, så et id ikke kan afprøves. |
| 409 | Fakturaen er i en tilstand, der forbyder dette. | Læs dens aktuelle status først. |
| 422 | Forespørslen er forkert opbygget, eller beløbet ligger uden for fakturaens grænser. | Beskeden angiver både den sendte værdi og grænsen. |
| 429 | For mange fakturaer denne time, for mange åbne ad gangen, eller for mange anmodninger. | Vent Retry-After ud, og prøv så igen. |
| 502 | Vi kunne ikke nå behandlingskernen. | Prøv igen med samme idempotensnøgle. |
Koder
Den form, vi selv afgør, er {"detail": {"code": …, "message": …}}. Det er de koder, merchant-API'et returnerer.
| Kode | Status | Betydning |
|---|---|---|
| invalid_api_key | 401 | Nøglen mangler, er forkert opbygget, ukendt eller tilbagekaldt. Alle fire besvares ens, så en nøgle ikke kan afprøves. |
| not_found | 404 | Intet sådant objekt, eller det tilhører en anden butik. |
| invalid_input | 422 | Anmodningen bestod ikke valideringen i kernen — et forkert beløb, for mange decimaler, et beløb uden for fakturaens grænser. |
| conflict | 409 | Handlingen strider mod den aktuelle tilstand, for eksempel at annullere en faktura, der ikke længere er åben. |
| too_many_requests | 429 | En hastighedsgrænse: fakturaer pr. time, åbne fakturaer eller anmodninger pr. minut. Retry-After siger, hvor længe du skal vente. |
| cbc_unreachable | 502 | Vi kunne ikke nå behandlingskernen. Prøv igen med samme idempotency_key. |
| webhook_url_rejected | 422 | Kun ved lagring af en nøgle: webhook-URL'en bestod ikke tjekkene ovenfor. detail.reason angiver hvilken regel — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials og så videre. |
En 502 betyder ikke, at fakturaen ikke blev oprettet — anmodningen kan være gået igennem med svaret tabt på vejen tilbage. Prøv igen med samme idempotency_key, og du får enten den eksisterende faktura eller en ny, aldrig to.
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.
Refusioner#
Sådan refunderer du en kunde.
Refunderinger går gennem support, ikke gennem et API-kald. En refusion er en ny overførsel til en adresse, som et menneske har oplyst, og en betalingsudbyder, der sender penge tilbage automatisk ved et API-kald, er en betalingsudbyder, som kan bringes til at sende penge til en angribers adresse. Derfor er det bevidst manuelt.
For at refundere en køber skal du oprette en supportsag fra dit kontoområde med invoice_id eller tx_hash, beløbet og den adresse, der skal sendes til. En operatør tjekker betalingen, flytter pengene ud af din saldo og svarer i samme sag. Regn med, at det tager en arbejdsdag, ikke et minut.
To konsekvenser, det er værd at indrette sig efter. Overbetaling krediteres dig fuldt ud — vi beholder intet af den — så det er din beslutning at sende differencen tilbage til en køber, der sendte for meget, og det går ad samme vej. Og en underbetalt faktura er ikke en refusionssag, mens den stadig er åben: pengene står på din saldo, adressen overvåges stadig, og køberen kan simpelthen efterbetale. Først efter henstandsperioden, når fakturaen bliver expired med penge på, er der en beslutning at træffe.
Test#
Sådan tester du din integration før lancering.
Nøglerne her er live: hver udstedt nøgle er en sk_live_-nøgle mod produktionskernen og TON-mainnet. Der er ikke noget separat testmiljø, og det har en fordel: du gennemløber præcis den vej, dine rigtige ordrer kommer til at tage.
Så test, som du ville teste alt andet, der rører rigtige penge: med små beløb. Opret en faktura på minimum (0.1 TON eller 3 USDT), betal den fra din egen tegnebog, og se hele vejen igennem — betalingssiden, webhooken, signaturtjekket, din ordre der skifter til betalt. Gebyret gælder, og mønterne flytter sig rigtigt.
De dele, du kan afprøve uden at bruge noget: at oprette og læse en faktura, at annullere en, 422 ved et forkert opbygget beløb, 401 ved en forkert nøgle, og din egen signaturverificering — signér en eksempel-body med din hemmelighed, og send den til din egen handler. Det, der reelt kræver en rigtig betaling, er kun det sidste trin: en faktisk payment.credited-webhook.
Planlæg integrationen, så den ikke afhænger af en sandkasse eller en simuleret betaling: den live vej er hurtigere — og mere retvisende — at verificere.
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.
Tjekliste før lancering#
Ti ting at tjekke inden lancering.
- Nøglen er kun på serversiden, aldrig i browser-JavaScript.
- Webhook-signaturen verificeres mod
"{timestamp}.{raw_body}", i konstant tid. - Leveringer, der er ældre end fem minutter, afvises, og serverens ur er på NTP.
- Et gentaget
X-Paysell-Event-Idgør intet anden gang. - Webhooken svarer 2xx inden for ti sekunder; det langsomme arbejde sker bagefter.
- Webhook-URL'en er et https://-domæne på port 443, uden en omdirigering foran.
- En mistet webhook kan overleves: faktura-endpointet læses på kvitteringssiden eller ved en afstemningsgennemgang.
idempotency_keygenereres én gang pr. ordre og genbruges ved gentagelser.- Beløb sendes som strenge i normale enheder; tallene fra webhooken læses som mindste enheder.
- Adressen vises nøjagtigt som returneret, uændret.
overpaidogunderpaidhåndteres, ikke kunpaid;expiredkan stadig indeholdepaid_minor.- Varen frigives ved
status: paidelleroverpaid, aldrig fordi kaldet blot er kommet frem. 429håndteres ved at venteRetry-Afterud, ikke ved at prøve igen med det samme.- Saldi læses fra os, spores ikke separat som sandhed.
Er noget uklart?
Hvis denne side ikke besvarede dit spørgsmål, er det et hul i dokumentationen, som er værd at fortælle os om. Skriv til os fra din kontoområde, så retter vi siden, ikke kun svaret.