Ta imot kryptobetalinger
Paysell gjør opp TON og USDT i TON-nettverket. Du oppretter en faktura, vi gir deg en lenke, og du får et signert callback så snart pengene er bekreftet på kjeden og godskrevet saldoen din.
Oversikt#
Hva Paysell gjør, og hva den ikke gjør.
Paysell er en betalingsformidler, ikke en lommebok. Du håndterer aldri private nøkler, overvåker ikke blokkjeden, og bestemmer ikke når en transaksjon er endelig — det tar vi oss av.
Hver faktura får sin egen mottaksadresse. Når en kjøper betaler den, venter vi på at nettverket bekrefter overføringen, trekker fra gebyret vårt, og godskriver resten på saldoen din. Du tar ut til hvilken som helst adresse.
Hvordan en betaling fungerer#
Seks trinn, de fleste våre.
Seks trinn, de fleste våre:
- 1
Kunden din klikker på betal
Serveren din kaller vårt API med beløpet og din egen ordrereferanse.
- 2
Vi tildeler en adresse
En fersk mottaksadresse hentes fra en forhåndsgenerert pool og knyttes til denne fakturaen. Én adresse tilhører nøyaktig én åpen faktura, og slik matches en betaling mot den.
- 3
Kunden sender myntene
De skanner QR-koden eller kopierer adressen. Send dem til
payment_urlvi returnerer, så håndteres siden for deg — beløp, adresse, QR, nedtelling, status i sanntid. - 4
Vi oppdager overføringen
To uavhengige kilder til blokkjededata spørres, og svarene deres sammenlignes. Hvis de er uenige, stopper vi i stedet for å velge det mest praktiske svaret.
- 5
Vi venter på endelighet
Inkludering i masterchain pluss tre blokker på toppen. Omtrent femten sekunder — en betaling som ser oppgjort ut og senere forsvinner, ville vært ditt tap, så vi tar ikke den risikoen.
- 6
Godskrevet, og du får beskjed
Gebyret trekkes fra, resten havner på saldoen din, og en signert webhook går til serveren din med din
order_id.
Fra betaling til callback: rundt et minutt — omtrent femten sekunder med nettverksbekreftelser, resten er vår gjennomgang av overvåkede adresser.
Hvor pengene går#
Gebyret, og hva det beregnes av.
Gebyret er 0,2 %, fast for butikken din fra det øyeblikket den registreres. Hvis standardsatsen endres senere, endres ikke din — den er skrevet inn i hver faktura som et tall, ikke som en referanse til en innstilling.
Gebyret tas fra det som faktisk kommer inn, ikke fra det fakturaen ba om. Fakturer 5 USDT og motta 20, så beregnes gebyret av 20. Betales det for lite, beregnes det av det som kom inn.
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 godskrives i sin helhet — vi beholder ikke differansen. Underbetaling lar fakturaen stå åpen slik at kjøperen kan etterbetale til samme adresse.
Hurtigstart#
Fem minutter til din første faktura.
Fem trinn. To er klikk i kontoområdet ditt, ett er én enkelt forespørsel fra serveren din, og de to siste skjer av seg selv.
- 1
Opprett en butikk
I kontoområdet ditt. Den begynner å ta imot betalinger umiddelbart — uten å vente på gjennomgang. Verifisering skjer stille i bakgrunnen og begrenser bare uttak, ikke innkommende betalinger.
- 2
Utsted en API-nøkkel
Butikken din → API-nøkler → Ny nøkkel. Nøkkelen og webhook-hemmeligheten vises én gang og aldri igjen. Oppbevar dem som du ville oppbevart et databasepassord, og send dem aldri til en nettleser.
- 3
Opprett en faktura
Én forespørsel fra serveren din, én lenke tilbake. De fire kodeeksemplene nedenfor sender alle nøyaktig det samme.
- 4
Send kjøperen til
payment_urlDet er hele kassen — beløp, adresse, QR-kode, nedtelling, status i sanntid — og det er ingenting å bygge. Se Kassen for hva kjøperen faktisk ser.
- 5
Vent på webhooken
Når pengene er bekreftet på kjeden og kreditert, sender vi en POST med en signert
payment.credited-hendelse til serveren din. Verifiser signaturen, og merk deretter ordren som betalt — men bare nårdata.statuserpaidelleroverpaid. Se Webhooker.
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 kjøperen til payment_url i svaret. Du er ferdig — resten kommer 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.
Autentisering#
API-nøkkelen din, og hvordan den brukes.
Hver forespørsel bærer nøkkelen din i Authorization-headeren:
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxAHver nøkkel som utstedes her, starter med sk_live_. Prefikset sk_test_ finnes bare i en installasjon som peker mot testnettverket, og ingen slik installasjon tilbys — se Testing. Vi lagrer en enveis-hash, ikke selve nøkkelen, så ingen, oss inkludert, kan vise den til deg igjen. Mistet den? Utsted en ny og trekk tilbake den gamle.
Butikken utledes fra nøkkelen, derfor tar ingen forespørsel noensinne en butikk-id. En nøkkel kan bare virke på sin egen butikk.
Stien bærer en versjon: /api/merchant/v1/…. Innenfor en versjon legger vi bare til felter — ingenting får nytt navn og ingenting endrer betydning i stillhet. En endring som ville brutt koden din får et nytt prefiks, /v2, og /v1 fortsetter å virke i en annonsert 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.
Opprett en faktura#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoicesForespørselstekst
| Felt | Type | Påkrevd | Beskrivelse |
|---|---|---|---|
| asset | string | ja | Enten TON eller USDT_TON. |
| amount | string | ja | Myntens normale enheter, som streng: "5" er 5 USDT. Ikke flere desimaler enn mynten har. Se Beløp. |
| order_id | string | nei | Din egen referanse, opptil 200 tegn. Kommer tilbake i hver webhook — slik matcher du en betaling med en ordre. |
| description | string | nei | Opptil 1000 tegn. Vises til kjøperen på betalingssiden. |
| ttl_minutes | number | nei | Hvor lenge fakturaen forblir betalbar, i minutter. 1–1440; utelat den, så gjelder standardverdien — 2 timer i dag. |
| idempotency_key | string | nei | Opptil 200 tegn. Send samme verdi ved nytt forsøk, og du får tilbake samme faktura i stedet for en ny. Et felt i kroppen, ikke headeren Idempotency-Key — den headeren leses 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"
}Hvordan bruke det i ordren din
| Felt | Hva du skal gjøre med det |
|---|---|
| invoice_id | Lagre den mot ordren din. Det er dette som identifiserer betalingen alle andre steder. |
| payment_url | Omdiriger kjøperen hit. Ikke noe annet å bygge. |
| address | Bare hvis du bygger din egen kasse. Vis den nøyaktig som gitt — se advarselen nedenfor. |
| amount | Beløpet i normale enheter, akkurat slik du sendte det. Vis dette. |
| amount_minor | Samme beløp som heltall i minste enhet. Regn med dette. |
| expires_at | Vis en nedtelling. Etter at den passerer, slutter adressen å bli overvåket for denne fakturaen. |
| status | Her er den alltid pending. Reelle endringer kommer via webhook. |
UQ… på mainnet, 0Q… på testnet). Å konvertere den, pynte på den, eller bytte den ut med en annen koding av samme adresse vil føre til at mynter sendt til en ennå ikke utplassert lommebok spretter tilbake til avsenderen.Les en faktura#
GET /api/merchant/v1/invoices/{invoice_id}
/api/merchant/v1/invoices/{invoice_id}Samme form som over, der status, paid og paid_minor gjenspeiler nåtiden: paid er hvor mye som har kommet inn i normale enheter, paid_minor det samme som et heltall i minste enhet. Nyttig som en reserveløsning når en webhook ble savnet, eller på en takkeside.
Spør den maks hvert par sekunder, og behandle webhooker som hovedkanalen. Fakturaer som tilhører en annen butikk svarer 404 — ikke 403, slik at en id ikke kan sonderes for eksistens.
Avbryt en faktura#
POST /api/merchant/v1/invoices/{invoice_id}/cancel
/api/merchant/v1/invoices/{invoice_id}/cancelLukker en faktura som fortsatt er åpen — pending eller underpaid — og frigjør adressen. Bruk det når kunden forlater kassen: adresser er en begrenset ressurs, og å returnere dem holder poolen sunn.
En faktura som ikke lenger er åpen svarer 409. Å avbryte en underpaid faktura sender ikke mynter tilbake til noen: penger som allerede er kreditert blir værende på saldoen din, og det eneste som lukkes er muligheten til å etterbetale.
Webhooker#
Hva som kommer, og hvordan verifisere det.
Angi en webhook-URL når du oppretter nøkkelen. Vi sender en POST dit når en betaling er kreditert — og når en innbetaling som er holdt tilbake for en ekstra kontroll blir avvist. Hver levering er signert, og vi fortsetter å prøve på nytt i omtrent halvannet døgn til du svarer 2xx. Frigi varen ved status: paid eller overpaid, ikke fordi kallet bare kom fram.
Hendelser
| Hendelse | Når | Hva kroppen bærer |
|---|---|---|
| payment.credited | Overføringen er bekreftet på kjeden, gebyret vårt er tatt, og resten ligger på saldoen din. | Feltene som er listet opp nedenfor. |
| payment.rejected | En innbetaling som ble holdt tilbake for en ekstra kontroll (se Statusreferanse) ble avvist. Pengene når ikke saldoen din. | invoice_id, order_id, asset, amount, tx_hash og reason. Ikke lever varene; var fakturaen allerede paid fra en tidligere overføring, handler denne hendelsen om den ekstra innbetalingen, ikke om den betalingen. |
Hva som kommer
{
"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 per hendelse; ligger også i headeren X-Paysell-Event-Id. Lagre den og ignorer gjentakelser — se nedenfor. |
| data.order_id | Din referanse. Slå opp ordren din med den. |
| data.amount | Hva kjøperen sendte i denne overføringen, i minste enhet — i motsetning til API-et, som tar normale enheter. |
| data.fee | Hva vi tok, i minste enhet. |
| data.credited | Hva som havnet på saldoen din: amount − fee, i minste enhet. |
| data.paid_minor | Totalt mottatt på denne fakturaen så langt, i minste enhet. Feltet som betyr noe ved underpaid: statusen sier at det kom inn mindre, dette sier hvor mye mindre. |
| data.asset | Mynten som faktisk kom inn. Ikke nødvendigvis mynten fakturaen ba om. |
| data.asset_mismatch | Finnes, og er true, bare når mynten som kom inn ikke er fakturaens mynt. Pengene krediteres deg, men fakturaen forblir ubetalt og status blir aldri paid. |
| data.invoice_asset | Følger med asset_mismatch: mynten fakturaen faktisk ber om. |
| data.status | Fakturaens status nå: pending, underpaid, paid, overpaid eller expired. Sammenlign med det du forventet. |
| data.tx_hash | Transaksjonen på kjeden, for dine registre 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 | Hendelsestypen: payment.credited eller payment.rejected. |
| X-Paysell-Event-Id | Unik per hendelse. Det er denne verdien du deduplikerer på. |
| X-Paysell-Timestamp | Da vi signerte, i unix-sekunder. Den er en del av den signerte strengen. |
| X-Paysell-Signature | sha256= etterfulgt av HMAC i heksadesimal. Se nedenfor. |
Verifisere signaturen
Hver forespørsel signeres med webhook-hemmeligheten som ble vist én gang da du opprettet nøkkelen. Signaturen er HMAC-SHA256(secret, "{timestamp}.{raw_body}") — tidsstempelet fra X-Paysell-Timestamp, et bokstavelig punktum, så bytene i kroppen. Sjekk den før du handler: uten dette kan hvem som helst som finner ut URL-en din, gi deg 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)
}Signer de rå kroppsbytene, nøyaktig som mottatt. Parser du JSON-en og serialiserer den på nytt, endres bytene — nøkkelrekkefølge, mellomrom — og signaturen vil ikke stemme. Sammenlign i konstant tid (hmac.compare_digest, crypto.timingSafeEqual): en vanlig == returnerer raskere når første byte er feil, og den forskjellen er nok til å gjette en signatur én byte om gangen.
Tidsstempelvinduet
Avvis alt hvis tidsstempel ligger mer enn fem minutter fra din egen klokke, i begge retninger. Tidsstempelet ligger inne i den signerte strengen nettopp for at det ikke skal kunne endres uten å ødelegge signaturen; vinduet er det som gjør dette til en beskyttelse. Uten det forblir en forespørsel som er fanget opp én gang gyldig for alltid og kan spilles av på nytt når som helst — signaturen alene utløper aldri. Hold serverklokken på NTP, ellers begynner denne sjekken å avvise gode leveranser.
Duplikater
Samme hendelse kan komme mer enn én gang. Det er ikke en feil: vi prøver på nytt til du svarer 2xx, og en levering som lyktes, men hvis svar aldri nådde oss, sendes på nytt. Registrer X-Paysell-Event-Id (den kommer også som event_id i kroppen) og sørg for at den andre ankomsten ikke gjør noe.
Nye forsøk
Første forsøk går ut så snart betalingen er kreditert. Hvis det mislykkes — tidsavbrudd, avvist tilkobling, TLS-feil, en omdirigering, eller en hvilken som helst status utenom 2xx — prøver vi på nytt etter en fast plan:
1 min → 5 min → 15 min → 1 t → 6 t → 24 tSju forsøk til sammen, spredt over omtrent 31 timer. De første ligger tett fordi den vanlige årsaken er en mottaker som holdt på å starte om igjen og allerede er tilbake; de siste ligger spredt fordi det ikke hjelper noen å hamre løs på en server som har vært nede et døgn.
Etter siste forsøk merkes leveringen dropped, og vi stopper av oss selv. Den er ikke tapt: betalingsraden i kontoområdet ditt viser tilstanden, antall forsøk og feilklassen, med en Send på nytt-knapp som starter en ny runde med alle sju forsøkene. Din andre utvei er GET /api/merchant/v1/invoices/{invoice_id} — fakturaen vet alltid sin egen status.
Hvordan en webhook-URL må se ut
URL-en kontrolleres når du lagrer den, og på nytt før hver eneste levering. En URL som ikke består kontrollen, besvares med 422 og code: "webhook_url_rejected" ved lagring, og merker leveringen failed — uten nye forsøk — hvis den begynner å feile senere. Reglene:
- Bare `https://`, og port 443. En webhook bærer betalingsdetaljer; over vanlig http er de lesbare for alle på veien.
- Et domenenavn, ikke en IP-adresse. Du trenger et sertifikat uansett, og sertifikater utstedes ikke for bare IP-er.
- Ingen `localhost`, og ingen
.local-,.internal-,.corp-,.lan- eller.test-navn — serverne våre når ikke nettverket ditt, og et navn som slår opp inne i vårt eget er nettopp det vi ikke må kalle. - Ingen legitimasjon i URL-en (
https://user:pass@…). Legg ditt eget token i stien eller i en spørringsparameter hvis du trenger et. - Hver adresse navnet slår opp til, må være offentlig — både A og AAAA. Private adresser, loopback, link-local og CGNAT-områder avvises, og kontrollen gjentas før hver levering, så å peke oppføringen mot
127.0.0.1senere fungerer heller ikke. - En omdirigering er en feil, ikke et hopp. Vi følger dem ikke: adressen du ga oss ble kontrollert, den i en
Location-header ble ikke det.
Svar raskt
Hvilken som helst 2xx holder, innen ti sekunder — det er hele tidsavbruddet vårt, tilkoblingen inkludert. Svar først, gjør det trege arbeidet etterpå; et endepunkt som venter på sin egen database før det svarer, blir før eller siden registrert som tidsavbrudd og forsøkt på nytt, og du behandler samme hendelse to ganger. Alt annet — en 4xx, en 5xx, en omdirigering, en henging — teller som et mislykket forsøk og går tilbake i planen ovenfor.
Levering, ærlig talt
Det som er garantert, er leveringsmekanismen: sju forsøk over omtrent 31 timer, en manuell ny sending fra kontoområdet ditt, og et faktura-endepunkt som alltid vet den virkelige statusen. Bygg flyten slik at en webhook som aldri kommer ikke koster deg noe — les fakturaen på takkesiden, eller avstem åpne fakturaer én gang i timen. Webhooker er den raske veien, ikke den eneste.
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.
Statusreferanse#
Alle faktura- og betalingsstatuser, forklart.
Faktura
| Status | Betydning | Hva du skal gjøre |
|---|---|---|
| pending | Venter på betaling. | Hold ordren åpen. |
| paid | Betalt i sin helhet. | Lever varene. |
| overpaid | Det kom inn mer enn forespurt. Overskuddet godskrives deg i sin helhet. | Lever varene; refunder differansen hvis du ønsker. |
| underpaid | Det kom inn mindre enn forespurt. Fakturaen forblir åpen og beholder adressen sin: kjøperen kan etterbetale til samme sted, og paid_minor sier hvor mye som allerede er inne. Den kan betales resten av levetiden sin pluss en frist på 24 timer etter expires_at. | Vent på etterbetalingen, eller gjør opp med kunden. Ikke lever varene — fakturaen er ikke betalt. |
| expired | Vinduet lukket seg, fristen inkludert. Kan fortsatt bære penger: det som kom inn ble værende på saldoen din, og paid_minor sier hvor mye. | Tilby en ny faktura. Ikke godta betaling til den gamle adressen: når en faktura utløper, går adressen tilbake til poolen, og en svært sen overføring blir en supportsak i stedet for en automatisk kreditering. Sjekk paid_minor før du sier til kunden at ingenting kom inn. |
| cancelled | Avbrutt av deg. Adressen frigis tilbake til poolen. | Ingenting. |
Betaling
Synlig i kontoområdet ditt; nyttig når du støtter en kunde midt i en betaling.
| Status | Betydning |
|---|---|
| detected | Sett på kjeden, venter på bekreftelser. |
| confirmed | Nettverket bekreftet det. Godskriving er neste. |
| credited | På saldoen din. Dette er når webhooken utløses. |
| review | Holdt tilbake for en ekstra kontroll — for eksempel mynter som kommer til en adresse uten en åpen faktura. |
| rejected | Ikke godskrevet. Årsaken er registrert. |
Når en betaling går til `review`
Noen innbetalinger holdes tilbake for en ekstra kontroll i stedet for å bli kreditert med en gang: et uvanlig stort beløp, mynter som kommer til en adresse uten åpen faktura, eller at de to blokkjedekildene vi spør er uenige om hva som skjedde. Ingenting går tapt — pengene venter på en avgjørelse, og webhooken utløses så snart den foreligger, noe som kan ta minutter eller timer. Behandle et manglende kall på en betaling som vises som review som normalt, ikke som en feil. Har det betydning for en ordre, kontakt support og oppgi tx_hash.
Beløp#
Normale enheter ut, minste enheter tilbake.
Send beløp i myntens normale enheter, som streng — "1.5" er halvannen. Ikke et JSON-tall og ikke den minste enheten.
| Aktivum | Desimaler | Du sender | amount_minor i svaret |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "1.5" | "1500000" |
En streng og ikke et tall, fordi JSON-tall er IEEE-754-doubler og et stort beløp i nanoton slutter å få plass eksakt i et slikt. Flere desimaler enn mynten har gir 422, aldri en stille avrunding av pengene dine. Webhooks går motsatt vei: der er amount, fee og credited heltall i minste enhet, for den siden leses av kode, ikke av 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. 1500000nGrenser#
Minimum, maksimum og hastighetsgrenser.
| Grense | Verdi | Ved brudd |
|---|---|---|
| Minimum faktura | 0.1 TON · 3 USDT | 422 |
| Maksimum faktura | 7000 TON · 10000 USDT | 422 |
| Fakturaer per time, per butikk | 60 | 429 |
| Åpne fakturaer samtidig | 20, øker med hver betalte faktura, opptil 200 | 429 |
| Fakturaens levetid | 1 minutt – 24 timer (standard 2 timer) | 422 |
| API-forespørsler per nøkkel | 120 per minutt | 429 + Retry-After |
Minimumet er ikke byråkrati. Gebyret vårt er en prosentandel, men å innkassere en betaling koster et fast beløp: å flytte USDT ut av en mottaksadresse betyr å fylle den med gass først, av vår egen lomme. Under noen få dollar dekker ikke gebyret behandlingen, og å godta en slik betaling ville bety å godskrive deg penger som er ulønnsomt å flytte.
Maksgrensen handler ikke om store forhandlere — den er en felle for enhetsfeilen. Send "5000000" der du mente "5", og du ville ellers fått en faktura på fem millioner dollar: kjøperen ser en absurd sum og går. En ekte ordre når aldri dette taket; en feil gjør det alltid. Begge takene er innstillinger (invoice_max_ton, invoice_max_usdt) og kan heves for butikken din — bare spør.
Timesgrensen og grensen for åpne fakturaer beskytter begge adressepoolen. Hver åpne faktura opptar en mottaksadresse, og en løpsk løkke på ett nettsted ville ellers tømme poolen for alle andre. En ny butikk kan ha 20 fakturaer åpne samtidig; kvoten vokser med én for hver faktura den faktisk har fått betalt, opp til et tak på 200. underpaid teller som åpen — den holder fortsatt på adressen sin og venter på resten. Å avbryte en forlatt faktura gir adressen tilbake umiddelbart. Nye forsøk med samme idempotency_key teller ikke mot timesgrensen.
Forespørselsgrensen er 120 per minutt per API-nøkkel — to kall i sekundet, godt over enhver reell ordreflyt. En 429 bærer en Retry-After-header i sekunder: vent så lenge i stedet for å prøve på nytt i en tett løkke, som bare skyver vinduet lenger ut.
Feil#
Statuskodene du faktisk vil se.
Feil kommer tilbake som JSON, i to former. Alt vi eller behandlingskjernen avgjør, legger et {code, message}-par under detail. En forespørselskropp som ikke består valideringen, legger i stedet en liste med feltfeil der. Sjekk hvilken av dem du fikk før du leser detail.code — og forgren på `code`, aldri på `message`: ordlyden kan endres når som helst, koden gjør det 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 | Når | Hva du skal gjøre |
|---|---|---|
| 401 | Nøkkelen mangler, er feil, eller er tilbakekalt. | Sjekk headeren. Utsted nøkkelen på nytt hvis den ble tilbakekalt. |
| 404 | Ingen slik faktura, eller den tilhører en annen butikk. | Sjekk id-en. De to tilfellene svarer likt med vilje, slik at en id ikke kan sonderes. |
| 409 | Fakturaen er i en tilstand som forbyr dette. | Les gjeldende status først. |
| 422 | Forespørselen er feilformet, eller beløpet er utenfor fakturaens grenser. | Meldingen navngir både verdien som ble sendt og grensen. |
| 429 | For mange fakturaer denne timen, for mange åpne samtidig, eller for mange forespørsler. | Vent ut Retry-After, og prøv igjen. |
| 502 | Vi kunne ikke nå behandlingskjernen. | Prøv igjen med samme idempotensnøkkel. |
Koder
Formen vi selv avgjør er {"detail": {"code": …, "message": …}}. Dette er kodene merchant-API-et returnerer.
| Kode | Status | Betydning |
|---|---|---|
| invalid_api_key | 401 | Nøkkelen mangler, er feilformet, ukjent eller tilbakekalt. Alle fire svarer likt, slik at en nøkkel ikke kan sonderes. |
| not_found | 404 | Objektet finnes ikke, eller det tilhører en annen butikk. |
| invalid_input | 422 | Forespørselen bestod ikke valideringen i kjernen — et ugyldig beløp, for mange desimaler, et beløp utenfor fakturagrensene. |
| conflict | 409 | Handlingen strider mot gjeldende tilstand, for eksempel å avbryte en faktura som ikke lenger er åpen. |
| too_many_requests | 429 | En hastighetsgrense: fakturaer per time, åpne fakturaer, eller forespørsler per minutt. Retry-After sier hvor lenge du skal vente. |
| cbc_unreachable | 502 | Vi kunne ikke nå behandlingskjernen. Prøv igjen med samme idempotency_key. |
| webhook_url_rejected | 422 | Bare ved lagring av en nøkkel: webhook-URL-en bestod ikke kontrollene ovenfor. detail.reason navngir hvilken regel — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials, og så videre. |
En 502 betyr ikke at fakturaen ikke ble opprettet — forespørselen kan ha gått gjennom med svaret tapt på veien tilbake. Prøv igjen med samme idempotency_key, og du får enten den eksisterende fakturaen eller en ny, aldri 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.
Refusjoner#
Hvordan du refunderer en kunde.
Refusjoner går via support, ikke via et API-kall. En refusjon er en ny overføring til en adresse et menneske har oppgitt, og en betalingsformidler som sender penger tilbake automatisk på et API-kall, er en betalingsformidler som kan lures til å sende penger til en angripers adresse. Derfor er det bevisst manuelt.
For å refundere en kjøper, opprett en supportsak fra kontoområdet ditt med invoice_id eller tx_hash, beløpet, og adressen det skal sendes til. En operatør sjekker betalingen, flytter pengene ut av saldoen din og svarer i samme sak. Regn med at dette tar en virkedag, ikke et minutt.
To konsekvenser det er verdt å bygge rundt. Overbetaling godskrives deg i sin helhet — vi beholder ikke noe av den — så å returnere differansen til en kjøper som sendte for mye er din avgjørelse og går samme vei. Og en underbetalt faktura er ikke en refusjonssak så lenge den er åpen: pengene er på saldoen din, adressen overvåkes fortsatt, og kjøperen kan bare etterbetale. Først etter fristen, når fakturaen går til expired med penger på seg, er det noe å bestemme.
Testing#
Hvordan du tester integrasjonen før lansering.
Nøklene her er ekte: hver nøkkel som utstedes, er en sk_live_-nøkkel mot produksjonskjernen og TON-hovednettet. Det finnes ikke noe separat testmiljø, og det har en fordel: du går nøyaktig den veien de virkelige ordrene dine vil gå.
Så test slik du ville testet hva som helst som berører ekte penger: på små beløp. Opprett en faktura på minimumet (0.1 TON eller 3 USDT), betal den fra din egen lommebok, og se hele veien — betalingssiden, webhooken, signatursjekken, ordren din som slår om til betalt. Gebyret gjelder, og myntene flytter seg på ekte.
Delene du kan øve på uten å bruke noe: opprette og lese en faktura, avbryte en, 422 ved et feilformet beløp, 401 ved feil nøkkel, og din egen signaturverifisering — signer en eksempelkropp med hemmeligheten din og mat den til din egen håndterer. Det som virkelig krever en ekte betaling, er bare siste steg: en faktisk payment.credited-webhook.
Planlegg integrasjonen slik at den ikke avhenger av en sandkasse eller en simulert betaling: den ekte veien er raskere — og ærligere — å verifisere.
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.
Sjekkliste før lansering#
Ti ting å sjekke før lansering.
- Nøkkelen er kun på serversiden, aldri i JavaScript i nettleseren.
- Webhook-signaturen verifiseres mot
"{timestamp}.{raw_body}", i konstant tid. - Leveranser eldre enn fem minutter avvises, og serverklokken går på NTP.
- En gjentatt
X-Paysell-Event-Idgjør ingenting andre gangen. - Webhooken svarer 2xx innen ti sekunder; tregt arbeid skjer etterpå.
- Webhook-URL-en er et https://-domene på port 443, uten en omdirigering foran seg.
- En tapt webhook er til å leve med: faktura-endepunktet leses på takkesiden eller i en avstemmingsrunde.
idempotency_keygenereres én gang per ordre og gjenbrukes ved nye forsøk.- Beløp sendes ut i normale enheter som strenger; tall fra webhooker leses som minste enheter.
- Adressen vises nøyaktig slik den ble returnert, uendret.
overpaidogunderpaidhåndteres, ikke barepaid;expiredkan fortsatt bærepaid_minor.- Varer frigis ved
status: paidelleroverpaid, aldri fordi kallet bare kom fram. 429håndteres ved å vente utRetry-After, ikke ved å prøve igjen med en gang.- Saldoer leses fra oss, ikke spores separat som sannhet.
Noe uklart?
Hvis denne siden ikke svarte på spørsmålet ditt, er det et hull i dokumentasjonen som er verdt å fortelle oss om. Skriv til oss fra kontoområdet ditt, så fikser vi siden, ikke bare svaret.