Paysell

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.

Saldi ligger hos os og er den eneste kilde til sandhed. Vis dem, men behold aldrig en anden kopi som autoritativ — to tællere ender altid med at afvige, og så ved ingen, hvilken der er korrekt.

Sådan fungerer en betaling#

Seks trin, de fleste af dem vores.

Seks trin, de fleste af dem vores:

  1. 1

    Din kunde klikker på betal

    Din server kalder vores API med beløbet og din egen ordrereference.

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

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 krediteres fuldt ud — vi beholder ikke differencen. Underbetaling holder fakturaen åben, så køberen kan efterbetale til samme adresse.

At få mønter væk fra en modtageradresse koster netværksgas, og den betaler vi — den del rører aldrig din saldo. Udbetaling til din egen adresse er en anden sag: den bærer sit eget gebyr, som trækkes fra det beløb, du beder om, og de præcise tal står i prislisten.

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

    Opret en faktura

    Én anmodning fra din server, ét link tilbage. De fire kodeeksempler nedenfor sender alle nøjagtig det samme.

  4. 4

    Send køberen til payment_url

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

Godkendelse#

Din API-nøgle, og hvordan den bruges.

Hver anmodning bærer din nøgle i Authorization-headeren:

http
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA

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

Denne nøgle opretter fakturaer i dit navn. Opbevar den på serversiden. Alt i browser-JavaScript er offentligt, uanset hvor godt det ser skjult ud.

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.

Opret en faktura#

POST /api/merchant/v1/invoices

POST/api/merchant/v1/invoices

Anmodningstekst

FeltTypePåkrævetBeskrivelse
assetstringjaEnten TON eller USDT_TON.
amountstringjaMøntens normale enheder, som streng: "5" er 5 USDT. Ikke flere decimaler end mønten har. Se Beløb.
order_idstringnejDin egen reference, op til 200 tegn. Kommer tilbage i hver webhook — sådan matcher du en betaling med en ordre.
descriptionstringnejOp til 1000 tegn. Vises til køberen på betalingssiden.
ttl_minutesnumbernejHvor længe fakturaen forbliver betalbar, i minutter. 1–1440; udelad den, så gælder standardværdien — 2 timer i dag.
idempotency_keystringnejOp 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

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

Sådan bruger du det i din ordre

FeltHvad du skal gøre med det
invoice_idGem den sammen med din ordre. Det er det, der identificerer betalingen alle andre steder.
payment_urlOmdiriger køberen hertil. Der er ikke andet at bygge.
addressKun hvis du bygger din egen betalingsside. Vis den nøjagtigt som modtaget — se advarslen nedenfor.
amountBeløbet i normale enheder, præcis som du sendte det. Vis dette.
amount_minorSamme beløb som heltal i mindste enhed. Regn med dette.
expires_atVis en nedtælling. Når den er udløbet, holder adressen op med at blive overvåget for denne faktura.
statusHer er den altid pending. Reelle ændringer ankommer via webhook.
Hvis du bygger din egen side, skal du udskrive adressen nøjagtigt som returneret. Den er i ikke-refunderbar form (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}

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

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

Lukker 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ændelseHvornårHvad kroppen indeholder
payment.creditedOverførslen er bekræftet på kæden, vores gebyr er trukket, og resten står på din saldo.Felterne nedenfor.
payment.rejectedEn 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

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 for hver hændelse; findes også i headeren X-Paysell-Event-Id. Gem den, og ignorer gentagelser — se nedenfor.
data.order_idDin reference. Slå din ordre op med den.
data.amountHvad køberen sendte i denne overførsel, i mindste enhed — i modsætning til API'et, der tager normale enheder.
data.feeHvad vi tog, i mindste enhed.
data.creditedHvad der landede på din saldo: amount − fee, i mindste enhed.
data.paid_minorSamlet 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.assetDen mønt, der faktisk kom ind. Ikke nødvendigvis den mønt, fakturaen bad om.
data.asset_mismatchFindes, 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_assetFølger med asset_mismatch: den mønt, fakturaen faktisk beder om.
data.statusFakturaens status nu: pending, underpaid, paid, overpaid eller expired. Sammenlign med det, du forventede.
data.tx_hashTransaktionen on-chain, til dine optegnelser 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-EventHændelsestypen: payment.credited eller payment.rejected.
X-Paysell-Event-IdUnik for hver hændelse. Det er den værdi, du skal deduplikere på.
X-Paysell-TimestampHvornår vi signerede, i unix-sekunder. Den indgår i den signerede streng.
X-Paysell-Signaturesha256= 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:

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

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 t

Syv 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.1 senere.
  • 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.
Verificeringen sker to gange med vilje — én gang når du gemmer URL'en, så en tastefejl besvares med det samme i stedet for med tavs manglende levering, og én gang før hver afsendelse, fordi ejeren af et domæne når som helst kan pege det mod en intern adresse. Hvis dit endpoint flytter, så opdater nøglen først: en afvist URL leverer intet og lægges ikke i kø.

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.

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.

Statusreference#

Alle faktura- og betalingsstatusser, forklaret.

Faktura

StatusBetydningHvad du skal gøre
pendingVenter på betaling.Hold ordren åben.
paidBetalt fuldt ud.Lever varerne.
overpaidDer kom mere ind, end der blev bedt om. Overskuddet krediteres dig fuldt ud.Lever varerne; refunder differencen, hvis du ønsker det.
underpaidDer 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.
expiredVinduet 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.
cancelledAnnulleret 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.

StatusBetydning
detectedSet on-chain, venter på bekræftelser.
confirmedNetværket bekræftede det. Kreditering er næste.
creditedPå din saldo. Dette er, når webhooken udløses.
reviewHoldt tilbage til et ekstra tjek — for eksempel mønter, der ankommer til en adresse uden en åben faktura.
rejectedIkke 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.

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

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

Grænser#

Minimum, maksimum og hastighedsgrænser.

GrænseVærdiVed overtrædelse
Minimumfaktura0.1 TON · 3 USDT422
Maksimumfaktura7000 TON · 10000 USDT422
Fakturaer pr. time, pr. butik60429
Åbne fakturaer ad gangen20, vokser med hver betalt faktura, op til 200429
Fakturaens levetid1 minut – 24 timer (standard 2 timer)422
API-anmodninger pr. nøgle120 pr. minut429 + 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.

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
    }
  ]
}
StatusHvornårHvad du skal gøre
401Nøglen mangler, er forkert, eller er tilbagekaldt.Tjek headeren. Genudsted nøglen, hvis den blev tilbagekaldt.
404Ingen 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.
409Fakturaen er i en tilstand, der forbyder dette.Læs dens aktuelle status først.
422Forespørslen er forkert opbygget, eller beløbet ligger uden for fakturaens grænser.Beskeden angiver både den sendte værdi og grænsen.
429For mange fakturaer denne time, for mange åbne ad gangen, eller for mange anmodninger.Vent Retry-After ud, og prøv så igen.
502Vi 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.

KodeStatusBetydning
invalid_api_key401Nøglen mangler, er forkert opbygget, ukendt eller tilbagekaldt. Alle fire besvares ens, så en nøgle ikke kan afprøves.
not_found404Intet sådant objekt, eller det tilhører en anden butik.
invalid_input422Anmodningen bestod ikke valideringen i kernen — et forkert beløb, for mange decimaler, et beløb uden for fakturaens grænser.
conflict409Handlingen strider mod den aktuelle tilstand, for eksempel at annullere en faktura, der ikke længere er åben.
too_many_requests429En hastighedsgrænse: fakturaer pr. time, åbne fakturaer eller anmodninger pr. minut. Retry-After siger, hvor længe du skal vente.
cbc_unreachable502Vi kunne ikke nå behandlingskernen. Prøv igen med samme idempotency_key.
webhook_url_rejected422Kun 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 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.

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.

Behandl din første rigtige ordre som den egentlige test: vælg et lille beløb, hold fakturaen åben i dashboardet, og tjek betalingsrækken og webhookens tilstand, før du sender rigtige kunder derhen.

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.

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-Id gø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_key genereres é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.
  • overpaid og underpaid håndteres, ikke kun paid; expired kan stadig indeholde paid_minor.
  • Varen frigives ved status: paid eller overpaid, aldrig fordi kaldet blot er kommet frem.
  • 429 håndteres ved at vente Retry-After ud, 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.