Paysell

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.

Saldoer ligger hos oss og er den eneste kilden til sannhet. Vis dem, men behold aldri en andre kopi som autoritativ — to tellere ender alltid opp med å avvike, og da vet ingen hvilken som er riktig.

Hvordan en betaling fungerer#

Seks trinn, de fleste våre.

Seks trinn, de fleste våre:

  1. 1

    Kunden din klikker på betal

    Serveren din kaller vårt API med beløpet og din egen ordrereferanse.

  2. 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. 3

    Kunden sender myntene

    De skanner QR-koden eller kopierer adressen. Send dem til payment_url vi returnerer, så håndteres siden for deg — beløp, adresse, QR, nedtelling, status i sanntid.

  4. 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. 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. 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.

example
Invoice:   5.000000 USDT
Received: 20.000000 USDT   (the buyer sent more)
Fee 0.2%:  0.040000 USDT   (on 20, not on 5)
Credited: 19.960000 USDT

Overbetaling godskrives i sin helhet — vi beholder ikke differansen. Underbetaling lar fakturaen stå åpen slik at kjøperen kan etterbetale til samme adresse.

Å få mynter ut fra en mottaksadresse koster nettverksgass, og den betaler vi — den delen berører aldri saldoen din. Uttak til din egen adresse er en annen sak: det bærer sitt eget gebyr, som trekkes fra beløpet du ber om, og de nøyaktige tallene står i prislisten.

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. 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. 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. 3

    Opprett en faktura

    Én forespørsel fra serveren din, én lenke tilbake. De fire kodeeksemplene nedenfor sender alle nøyaktig det samme.

  4. 4

    Send kjøperen til payment_url

    Det 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. 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år data.status er paid eller overpaid. 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

Autentisering#

API-nøkkelen din, og hvordan den brukes.

Hver forespørsel bærer nøkkelen din i Authorization-headeren:

http
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA

Hver 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.

Denne nøkkelen oppretter fakturaer i ditt navn. Hold den på serversiden. Alt i nettleser-JavaScript er offentlig, uansett hvor godt det ser skjult ut.

Endpoints at a glance#

Four calls, three of them authenticated.

This is the whole merchant API. Balances, payouts and history are not in it — they live in your account area, where a person is looking at them.

EndpointMethodAuthWhat it does
/invoicesPOSTAPI keyOpen an invoice and get a payment link. Details.
/invoices/{invoice_id}GETAPI keyRead one invoice's current state. Details.
/invoices/{invoice_id}/cancelPOSTAPI keyClose an invoice that is still open and free its address. Details.
/public/invoices/{invoice_id}GETnoneWhat the hosted checkout page reads. Only needed if you build your own. Details.

Every path is relative to https://paysell.me/api/merchant/v1. There is no list endpoint and no refund endpoint — see Refunds.

Opprett en faktura#

POST /api/merchant/v1/invoices

POST/api/merchant/v1/invoices

Forespørselstekst

FeltTypePåkrevdBeskrivelse
assetstringjaEnten TON eller USDT_TON.
amountstringjaMyntens normale enheter, som streng: "5" er 5 USDT. Ikke flere desimaler enn mynten har. Se Beløp.
order_idstringneiDin egen referanse, opptil 200 tegn. Kommer tilbake i hver webhook — slik matcher du en betaling med en ordre.
descriptionstringneiOpptil 1000 tegn. Vises til kjøperen på betalingssiden.
ttl_minutesnumberneiHvor lenge fakturaen forblir betalbar, i minutter. 1–1440; utelat den, så gjelder standardverdien — 2 timer i dag.
idempotency_keystringneiOpptil 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

json
{
  "invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
  "payment_url": "https://paysell.me/pay/12c22c1a-a496-4c1e-abe3-72661ef8706e",
  "address": "UQAvDJp7QDwqRcuNQBiK2GhBt71Xh1_UMYPCzMkQAoBPmZKl",
  "asset": "USDT_TON",
  "amount": "5",
  "amount_minor": "5000000",
  "status": "pending",
  "paid": "0",
  "paid_minor": "0",
  "order_id": "order-1042",
  "description": "Pro subscription",
  "expires_at": "2026-09-06T17:20:55Z",
  "created_at": "2026-09-06T15:20:55Z"
}

Hvordan bruke det i ordren din

FeltHva du skal gjøre med det
invoice_idLagre den mot ordren din. Det er dette som identifiserer betalingen alle andre steder.
payment_urlOmdiriger kjøperen hit. Ikke noe annet å bygge.
addressBare hvis du bygger din egen kasse. Vis den nøyaktig som gitt — se advarselen nedenfor.
amountBeløpet i normale enheter, akkurat slik du sendte det. Vis dette.
amount_minorSamme beløp som heltall i minste enhet. Regn med dette.
expires_atVis en nedtelling. Etter at den passerer, slutter adressen å bli overvåket for denne fakturaen.
statusHer er den alltid pending. Reelle endringer kommer via webhook.
Hvis du bygger din egen side, skriv ut adressen nøyaktig som returnert. Den er i ikke-refunderbar form (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}

GET/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

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

Lukker 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

HendelseNårHva kroppen bærer
payment.creditedOverføringen er bekreftet på kjeden, gebyret vårt er tatt, og resten ligger på saldoen din.Feltene som er listet opp nedenfor.
payment.rejectedEn 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

json
{
  "event_id": "99f74f58-efbb-4af1-b0a3-76b0073f9e6b",
  "type": "payment.credited",
  "data": {
    "invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
    "order_id": "order-1042",
    "asset": "USDT_TON",
    "amount": "5000000",
    "credited": "4905000",
    "fee": "95000",
    "status": "paid",
    "paid_minor": "5000000",
    "tx_hash": "97a1f0…"
  }
}
json
{
  "event_id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d",
  "type": "payment.rejected",
  "data": {
    "invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
    "order_id": "order-1042",
    "asset": "USDT_TON",
    "amount": "5000000",
    "tx_hash": "97a1f0…",
    "reason": "could not be matched to any order"
  }
}

Feltmapping

FeltBetydning
event_idUnik per hendelse; ligger også i headeren X-Paysell-Event-Id. Lagre den og ignorer gjentakelser — se nedenfor.
data.order_idDin referanse. Slå opp ordren din med den.
data.amountHva kjøperen sendte i denne overføringen, i minste enhet — i motsetning til API-et, som tar normale enheter.
data.feeHva vi tok, i minste enhet.
data.creditedHva som havnet på saldoen din: amount − fee, i minste enhet.
data.paid_minorTotalt 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.assetMynten som faktisk kom inn. Ikke nødvendigvis mynten fakturaen ba om.
data.asset_mismatchFinnes, 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_assetFølger med asset_mismatch: mynten fakturaen faktisk ber om.
data.statusFakturaens status nå: pending, underpaid, paid, overpaid eller expired. Sammenlign med det du forventet.
data.tx_hashTransaksjonen på kjeden, for dine registre og support.

Headere på hver levering

http
X-Paysell-Event:      payment.credited
X-Paysell-Event-Id:   99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp:  1789000000
X-Paysell-Signature:  sha256=6f1c0e6a…
HeaderBetydning
X-Paysell-EventHendelsestypen: payment.credited eller payment.rejected.
X-Paysell-Event-IdUnik per hendelse. Det er denne verdien du deduplikerer på.
X-Paysell-TimestampDa vi signerte, i unix-sekunder. Den er en del av den signerte strengen.
X-Paysell-Signaturesha256= 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:

python
import hmac, hashlib, time

def is_ours(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:      # ±5 minutes
        return False
    signed = timestamp.encode() + b"." + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    # compare_digest, not ==: a plain comparison leaks the answer through timing
    return hmac.compare_digest("sha256=" + expected, signature)

Node.js:

javascript
const crypto = require("node:crypto")

function isOurs(body, signature, timestamp, secret) {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false  // ±5 min
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(timestamp + ".").update(body)   // body is the raw Buffer, not a parsed object
    .digest("hex")
  const a = Buffer.from(expected), b = Buffer.from(signature)
  // timingSafeEqual throws when the lengths differ, so check that first
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

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 t

Sju 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.1 senere 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.
Kontrollen skjer to ganger med vilje — én gang når du lagrer URL-en, slik at en skrivefeil besvares med en gang i stedet for med stille uteblitt levering, og én gang før hver sending, fordi den som eier et domene kan peke det mot en intern adresse når som helst. Flytter endepunktet ditt, oppdater nøkkelen først: en avvist URL leverer ingenting og legges ikke i kø.

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.

javascript
const express = require("express")
const crypto = require("node:crypto")

const app = express()
const SECRET = process.env.PAYSELL_WEBHOOK_SECRET

function isOurs(body, signature, timestamp) {
  const sentAt = Number(timestamp)
  if (!Number.isFinite(sentAt)) return false
  if (Math.abs(Date.now() / 1000 - sentAt) > 300) return false   // ±5 minutes

  const expected = "sha256=" + crypto
    .createHmac("sha256", SECRET)
    .update(timestamp + ".").update(body)      // raw Buffer, not a parsed object
    .digest("hex")

  const a = Buffer.from(expected), b = Buffer.from(signature ?? "")
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

app.post(
  "/paysell/webhook",
  express.raw({ type: "application/json" }),   // NOT express.json()
  async (req, res) => {
    const signature = req.get("X-Paysell-Signature")
    const timestamp = req.get("X-Paysell-Timestamp")
    if (!isOurs(req.body, signature, timestamp)) return res.sendStatus(401)

    const event = JSON.parse(req.body.toString("utf8"))

    // Answer first: 10 seconds is the whole timeout, connection included.
    res.sendStatus(200)

    // Deduplicate. In real code this is a unique column, not a Set.
    if (await alreadyHandled(event.event_id)) return
    await remember(event.event_id)

    if (event.type !== "payment.credited") return
    const { order_id, status, credited, asset, tx_hash } = event.data

    // The only condition that may release the goods.
    if (status !== "paid" && status !== "overpaid") return
    await markOrderPaid(order_id, { credited, asset, tx_hash })
  }
)

Python with Flask. request.get_data() is the raw body; request.form and request.json are not.

python
import hashlib
import hmac
import json
import os
import time

from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["PAYSELL_WEBHOOK_SECRET"]


def is_ours(body: bytes, signature: str, timestamp: str) -> bool:
    try:
        sent_at = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(time.time() - sent_at) > 300:              # ±5 minutes
        return False

    signed = timestamp.encode() + b"." + body
    expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest("sha256=" + expected, signature)


@app.post("/paysell/webhook")
def paysell_webhook():
    body = request.get_data()                          # raw bytes, unparsed
    if not is_ours(body, request.headers.get("X-Paysell-Signature", ""),
                   request.headers.get("X-Paysell-Timestamp", "")):
        return "", 401

    event = json.loads(body)

    # Deduplicate. In real code this is a unique column, not a set.
    if already_handled(event["event_id"]):
        return "", 200                                 # a repeat is still a success
    remember(event["event_id"])

    if event["type"] == "payment.credited":
        data = event["data"]
        # The only condition that may release the goods.
        if data["status"] in ("paid", "overpaid"):
            mark_order_paid(data["order_id"], data)

    return "", 200                                     # 2xx within 10 seconds

What the code is doing, and why

  • Verify before anything acts on the body. An unsigned request that reaches your business logic is a paid order for whoever found your URL.
  • Answer 2xx first, work afterwards. Ten seconds is the whole timeout, connection included. A handler that waits for its own database gets recorded as a timeout and retried, and you process the same event twice.
  • Deduplicate on `event_id` in storage that survives a restart. The in-memory set in the examples keeps them short; a real one is a unique column in your database.
  • Mark the order paid only on `status: paid` or `overpaid`. underpaid means part of the money arrived and the invoice is still open, and a deposit in the wrong coin never makes an invoice paid either.
  • Answer 2xx to a duplicate too. A repeat that gets a 4xx looks like a failure to us and comes back again on the schedule.

payment.rejected arrives at the same endpoint. It means a deposit held for an additional check was declined and the money will not be credited: release nothing, and if the invoice was already paid by an earlier transfer, this event is about the extra deposit, not about that payment.

Statusreferanse#

Alle faktura- og betalingsstatuser, forklart.

Faktura

StatusBetydningHva du skal gjøre
pendingVenter på betaling.Hold ordren åpen.
paidBetalt i sin helhet.Lever varene.
overpaidDet kom inn mer enn forespurt. Overskuddet godskrives deg i sin helhet.Lever varene; refunder differansen hvis du ønsker.
underpaidDet 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.
expiredVinduet 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.
cancelledAvbrutt 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.

StatusBetydning
detectedSett på kjeden, venter på bekreftelser.
confirmedNettverket bekreftet det. Godskriving er neste.
creditedPå saldoen din. Dette er når webhooken utløses.
reviewHoldt tilbake for en ekstra kontroll — for eksempel mynter som kommer til en adresse uten en åpen faktura.
rejectedIkke 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.

AktivumDesimalerDu senderamount_minor i svaret
TON9"1.5""1500000000"
USDT_TON6"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.

javascript
// Send amounts in the coin's normal units, as a string:
const amount = "1.5"   // one and a half TON or USDT

// In responses, amount is that same human string; amount_minor is the
// integer in smallest units — use it for exact maths, as a string or BigInt:
BigInt(invoice.amount_minor)  // e.g. 1500000n

Grenser#

Minimum, maksimum og hastighetsgrenser.

GrenseVerdiVed brudd
Minimum faktura0.1 TON · 3 USDT422
Maksimum faktura7000 TON · 10000 USDT422
Fakturaer per time, per butikk60429
Åpne fakturaer samtidig20, øker med hver betalte faktura, opptil 200429
Fakturaens levetid1 minutt – 24 timer (standard 2 timer)422
API-forespørsler per nøkkel120 per minutt429 + 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.

json
{
  "detail": {
    "code": "invalid_input",
    "message": "invoice amount below the minimum: 0.010000 USDT_TON, minimum 3.000000 USDT_TON"
  }
}
json
{
  "detail": [
    {
      "type": "string_type",
      "loc": ["body", "amount"],
      "msg": "Input should be a valid string",
      "input": 5
    }
  ]
}
StatusNårHva du skal gjøre
401Nøkkelen mangler, er feil, eller er tilbakekalt.Sjekk headeren. Utsted nøkkelen på nytt hvis den ble tilbakekalt.
404Ingen 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.
409Fakturaen er i en tilstand som forbyr dette.Les gjeldende status først.
422Forespørselen er feilformet, eller beløpet er utenfor fakturaens grenser.Meldingen navngir både verdien som ble sendt og grensen.
429For mange fakturaer denne timen, for mange åpne samtidig, eller for mange forespørsler.Vent ut Retry-After, og prøv igjen.
502Vi 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.

KodeStatusBetydning
invalid_api_key401Nøkkelen mangler, er feilformet, ukjent eller tilbakekalt. Alle fire svarer likt, slik at en nøkkel ikke kan sonderes.
not_found404Objektet finnes ikke, eller det tilhører en annen butikk.
invalid_input422Forespørselen bestod ikke valideringen i kjernen — et ugyldig beløp, for mange desimaler, et beløp utenfor fakturagrensene.
conflict409Handlingen strider mot gjeldende tilstand, for eksempel å avbryte en faktura som ikke lenger er åpen.
too_many_requests429En hastighetsgrense: fakturaer per time, åpne fakturaer, eller forespørsler per minutt. Retry-After sier hvor lenge du skal vente.
cbc_unreachable502Vi kunne ikke nå behandlingskjernen. Prøv igjen med samme idempotency_key.
webhook_url_rejected422Bare 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 is underpaid.
  • The invoice's short id with a copy button, so a buyer can quote it to your support.
  • Wallet buttons: Tonkeeper and MyTonWallet open with the address and the amount already filled in. Other reveals a QR code and the address with a copy button.
  • A warning that only this invoice's coin, on the TON network, may be sent — anything else is lost.

How the page reacts

InvoiceWhat the buyer sees
pending“Waiting for payment”, with the wallet choices and the countdown. The page re-reads the invoice every five seconds.
underpaid“Received X of Y”, the exact remainder still owed, and the same address to send it to. The wallet link is prefilled with what is missing, not with the original total — otherwise the buyer would pay twice.
paid · overpaid“Payment received”, and a button back to your shop if the shop has a URL.
expired“Payment window closed”. If money did arrive, the amount is named with a note to contact you — silence here would send the buyer looking for their coins.
cancelled“Payment cancelled”, with a link back to your shop.

If you build your own

You gain your own branding and take on all of the above: the exact address string, the right coin, the countdown with its grace period, the underpayment case, and polling. GET /api/merchant/v1/public/invoices/{invoice_id} is the same unauthenticated read the hosted page uses — rate limited per IP, so poll it no more often than every few seconds. Print the address exactly as returned.

Typical integration mistakes#

The handful that account for most broken integrations.

None of these are exotic. Every one of them has cost somebody a day.

  • Sending the amount as a number

    {"amount": 5} is a 422. It has to be the string "5": JSON numbers are IEEE-754 doubles, and a large sum in nanotons stops being exactly representable in one.

  • Sending the smallest unit

    "5000000" for 5 USDT is a mistake in units, and the upper invoice limit exists to catch it. Smallest units are what comes back in webhooks, not what goes out in requests.

  • Treating the webhook's arrival as payment

    Read data.status. underpaid is not paid, and a deposit in a coin the invoice did not ask for never makes it paid either. Release the goods on paid or overpaid, on nothing else.

  • Not checking the timestamp

    A signature on its own never expires. Without the ±5 minute window on X-Paysell-Timestamp, a delivery captured once can be replayed at any time and will still verify.

  • Verifying the signature over re-serialised JSON

    Parse the body and serialise it again and the bytes change — key order, spacing — and the HMAC no longer matches. Sign the raw bytes exactly as received.

  • No deduplication

    The same event_id will arrive twice sooner or later: we retry until you answer 2xx, and a response lost on the way back looks like a failure from here. The second arrival must do nothing.

  • Using the Idempotency-Key header

    This API reads idempotency_key from the request body; the header is not read at all. A retry without the field opens a second invoice for the same order.

  • Assuming detail is always an object

    It is {code, message} for anything we or the core decide, and a list of field errors when the body itself fails validation. Check which one you got before reading detail.code.

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.

Behandle den første ekte ordren din som selve testen: velg et lite beløp, hold fakturaen åpen i kontrollpanelet, og sjekk betalingsraden og webhook-tilstanden før du sender ekte kunder dit.

For AI agents and LLMs#

Machine-readable copies of this page, and a prompt to start from.

Everything on this page also exists in a form a model can read directly. Point your assistant at one of these instead of pasting screenshots of documentation into a chat.

The three files

FileWhat it isUse it for
/llms-full.txtThe whole documentation as one markdown file: endpoints, fields, statuses, limits, errors, webhooks with working verification code, the fee, the checkout page, the checklist.Pasting into a model's context, or letting an agent fetch it. Start here.
/llms.txtA short index in the llms.txt format: what Paysell is, the five rules that decide whether an integration works, and links to everything else.Letting an agent discover the rest on its own.
/openapi.jsonOpenAPI 3.1, generated from the running application's own models, both webhook events included.Generating a client, or loading into anything that speaks OpenAPI.

A prompt to start from

Copy this, replace the stack, and hand it to your assistant. It names the four things that go wrong most often, so the answer does not have to be corrected afterwards.

prompt
Read https://paysell.me/llms-full.txt and implement Paysell payments in my <stack>:
create invoices (POST /api/merchant/v1/invoices, Bearer sk_live_ key, amount as a
decimal string in normal units), redirect the buyer to payment_url, verify webhook
signatures (HMAC-SHA256 over "{timestamp}.{raw_body}", header X-Paysell-Signature,
reject anything whose X-Paysell-Timestamp is more than 300 seconds off), deduplicate
by event_id, answer 2xx within 10 seconds, and mark orders paid only on a
payment.credited event whose data.status is "paid" or "overpaid".

Feeding it to a specific tool

  • Agents with web access — Claude Code, Cursor, Windsurf and the like: give them the /llms-full.txt link. One fetch, no setup.
  • A chat window — ChatGPT, Claude, Gemini: paste the contents of /llms-full.txt into the conversation or attach it as a file. It is written to fit in one message.
  • OpenAPI tooling — client generators, Postman, an agent's tool schema: point it at https://paysell.me/openapi.json. Its servers entry already carries the production base URL, so generated calls go to the right place.

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-Id gjø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_key genereres é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.
  • overpaid og underpaid håndteres, ikke bare paid; expired kan fortsatt bære paid_minor.
  • Varer frigis ved status: paid eller overpaid, aldri fordi kallet bare kom fram.
  • 429 håndteres ved å vente ut Retry-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.