Paysell

Ta emot kryptobetalningar

Paysell avvecklar TON och USDT på TON-nätverket. Du skapar en faktura, vi ger dig en länk, och du får en signerad callback när pengarna är bekräftade på kedjan och krediterade ditt saldo.

Översikt#

Vad Paysell gör, och vad det inte gör.

Paysell är en betalningsprocessor, inte en plånbok. Du hanterar aldrig privata nycklar, övervakar inte blockkedjan och avgör inte när en transaktion är slutgiltig — det är den del vi tar på oss.

Varje faktura får sin egen mottagaradress. När en köpare betalar den väntar vi tills nätverket bekräftat överföringen, drar vår avgift och krediterar resten till ditt saldo. Du tar ut till valfri adress.

Saldon finns hos oss och är den enda sanningskällan. Visa dem, men behåll aldrig en andra kopia som auktoritativ — två räknare glider förr eller senare isär, och då vet ingen vilken som stämmer.

Så fungerar en betalning#

Sex steg, de flesta våra.

Sex steg, de flesta våra:

  1. 1

    Din kund klickar på betala

    Din server anropar vårt API med beloppet och din egen orderreferens.

  2. 2

    Vi delar ut en adress

    En färsk mottagaradress hämtas från en förgenererad pool och knyts till denna faktura. En adress tillhör exakt en öppen faktura, vilket är hur en betalning matchas tillbaka till den.

  3. 3

    Kunden skickar mynt

    De skannar QR-koden eller kopierar adressen. Skicka dem till den payment_url vi returnerar så hanteras sidan åt dig — belopp, adress, QR, nedräkning, status i realtid.

  4. 4

    Vi upptäcker överföringen

    Två oberoende källor till blockkedjedata avfrågas, och deras svar jämförs. Om de inte stämmer överens stannar vi upp istället för att välja det bekvämare svaret.

  5. 5

    Vi väntar på finalitet

    Inkludering i masterchain plus tre block ovanpå. Ungefär femton sekunder — en betalning som ser avvecklad ut men senare försvinner skulle vara din förlust, så vi tar inte den risken.

  6. 6

    Krediterad, och du blir underrättad

    Avgiften dras av, resten hamnar på ditt saldo, och en signerad webhook skickas till din server med ditt order_id.

Ungefär en minut från betalning till callback: ungefär femton sekunder för nätverksbekräftelser, resten är vår genomsökning av bevakade adresser.

Vart pengarna tar vägen#

Avgiften, och vad den beräknas på.

Avgiften är 0,2 %, fast för din butik vid tidpunkten den registreras. Om standardavgiften ändras senare gör inte din det — den skrivs in i varje faktura som ett tal, inte som en referens till en inställning.

Avgiften tas från det som faktiskt kommer in, inte från det fakturan begärde. Fakturera 5 USDT och ta emot 20, så beräknas avgiften på 20. Betala för lite, och den beräknas på det som kom in.

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

Överbetalning krediteras i sin helhet — vi behåller inte mellanskillnaden. Underbetalning lämnar fakturan öppen så köparen kan fylla på till samma adress.

Att få loss mynt från en mottagaradress kostar nätverksgas, och den betalar vi — den delen rör aldrig ditt saldo. Uttag till din egen adress är en annan sak: det bär sin egen avgift, som dras från beloppet du begär, och de exakta siffrorna finns i avgiftslistan.

Snabbstart#

Fem minuter till din första faktura.

Fem steg. Två är klick i ditt kontoområde, ett är en enda förfrågan från din server, och de två sista sker av sig själva.

  1. 1

    Skapa en butik

    I ditt kontoområde. Den börjar ta emot betalningar omedelbart — ingen väntan på granskning. Verifiering sker tyst i bakgrunden och begränsar bara uttag, inte inkommande betalningar.

  2. 2

    Utfärda en API-nyckel

    Din butik → API-nycklar → Ny nyckel. Nyckeln och webhook-hemligheten visas en gång och aldrig mer. Förvara dem som du förvarar ett databaslösenord, och skicka dem aldrig till en webbläsare.

  3. 3

    Skapa en faktura

    En förfrågan från din server, en länk tillbaka. De fyra kodexemplen nedan skickar alla exakt samma sak.

  4. 4

    Skicka köparen till payment_url

    Det är hela kassan — belopp, adress, QR-kod, nedräkning, status i realtid — och det finns inget att bygga. Se Kassan för vad köparen faktiskt ser.

  5. 5

    Vänta på webhooken

    När pengarna är bekräftade på kedjan och krediterade gör vi en POST med en signerad payment.credited-händelse till din server. Verifiera signaturen och markera sedan ordern som betald — men bara när data.status är 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"
  }'

Omdirigera köparen till payment_url i svaret. Du är klar — resten kommer som en webhook.

What to do next

Autentisering#

Din API-nyckel, och hur den används.

Varje förfrågan bär din nyckel i Authorization-huvudet:

http
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA

Varje nyckel som utfärdas här börjar med sk_live_. Prefixet sk_test_ finns bara i en driftsättning som pekar mot testnätverket, och ingen sådan driftsättning erbjuds — se Testning. Vi lagrar en envägshash, inte själva nyckeln, så ingen, oss inkluderat, kan visa den för dig igen. Tappat bort den? Utfärda en ny och återkalla den gamla.

Butiken härleds från nyckeln, vilket är varför ingen förfrågan någonsin tar ett butiks-id. En nyckel kan bara agera på sin egen butik.

Sökvägen bär en version: /api/merchant/v1/…. Inom en version lägger vi bara till fält — inget byter namn och inget ändrar tyst betydelse. En ändring som skulle förstöra din kod får ett nytt prefix, /v2, och /v1 fortsätter fungera under en aviserad tid.

Denna nyckel skapar fakturor i ditt namn. Håll den serversidan. Allt i webbläsarens JavaScript är offentligt, oavsett hur väl gömt det ser 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.

Skapa en faktura#

POST /api/merchant/v1/invoices

POST/api/merchant/v1/invoices

Förfrågningskropp

FältTypObligatorisktBeskrivning
assetstringjaAntingen TON eller USDT_TON.
amountstringjaMyntets normala enheter, som sträng: "5" är 5 USDT. Inte fler decimaler än myntet har. Se Belopp.
order_idstringnejDin egen referens, upp till 200 tecken. Kommer tillbaka i varje webhook — så här matchar du en betalning mot en order.
descriptionstringnejUpp till 1000 tecken. Visas för köparen på betalningssidan.
ttl_minutesnumbernejHur länge fakturan förblir betalbar, i minuter. 1–1440; utelämna det så gäller standardvärdet — 2 timmar i dag.
idempotency_keystringnejUpp till 200 tecken. Skicka samma värde vid återförsök så får du tillbaka samma faktura i stället för en andra. Ett fält i kroppen, inte huvudet Idempotency-Key — det huvudet läses inte här.

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

Mappa det mot din order

FältVad du ska göra med det
invoice_idSpara det mot din order. Det är vad som identifierar betalningen överallt annars.
payment_urlOmdirigera köparen hit. Inget annat att bygga.
addressEndast om du renderar din egen kassa. Visa den exakt som den gavs — se varningen nedan.
amountBeloppet i normala enheter, precis som du skickade det. Visa detta.
amount_minorSamma belopp som heltal i minsta enhet. Räkna med detta.
expires_atVisa en nedräkning. Efter den passerat slutar adressen att bevakas för denna faktura.
statusAlltid pending här. Verkliga ändringar kommer via webhook.
Om du renderar din egen sida, skriv ut adressen exakt som den returnerades. Den är i icke-studsande form (UQ… på huvudnätet, 0Q… på testnätet). Konvertera den, försköna den, eller byt ut den mot en annan kodning av samma adress, och mynt skickade till en ännu ej utplacerad plånbok studsar tillbaka till avsändaren.

Läsa en faktura#

GET /api/merchant/v1/invoices/{invoice_id}

GET/api/merchant/v1/invoices/{invoice_id}

Samma form som ovan, där status, paid och paid_minor återspeglar nuläget: paid är hur mycket som kommit in i normala enheter, paid_minor samma sak som heltal i minsta enhet. Användbart som en reserv när en webhook missades, eller på en tacksida.

Fråga den högst var några sekunder, och behandla webhooks som den primära kanalen. Fakturor som tillhör en annan butik svarar 404 — inte 403, så ett id kan inte avsökas för existens.

Avbryta en faktura#

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

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

Stänger en faktura som fortfarande är öppen — pending eller underpaid — och släpper dess adress. Använd det när kunden överger kassan: adresser är en ändlig resurs, och att lämna tillbaka dem håller poolen frisk.

En faktura som inte längre är öppen svarar 409. Att avbryta en underpaid faktura skickar inte tillbaka mynt till någon: pengar som redan krediterats ligger kvar på ditt saldo, och det enda som stängs är möjligheten att fylla på.

Webhooks#

Vad som kommer, och hur man verifierar det.

Ange en webhook-URL när du skapar nyckeln. Vi gör en POST dit när en betalning krediteras — och när en insättning som hållits för en extra kontroll avslås. Varje leverans är signerad, och vi fortsätter försöka i ungefär ett och ett halvt dygn tills du svarar 2xx. Släpp varan vid status: paid eller overpaid — inte för att anropet bara kommit fram.

Händelser

HändelseNärVad kroppen innehåller
payment.creditedÖverföringen är bekräftad på kedjan, vår avgift är dragen och resten ligger på ditt saldo.Fälten som listas nedan.
payment.rejectedEn insättning som hölls för en extra kontroll (se Statusreferens) har avslagits. Pengarna når inte ditt saldo.invoice_id, order_id, asset, amount, tx_hash och reason. Släpp inte varan; om fakturan redan var paid från en tidigare överföring gäller den här händelsen den extra insättningen, inte den betalningen.

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

Fältmappning

FältBetydelse
event_idUnikt per händelse; finns också i huvudet X-Paysell-Event-Id. Spara det och ignorera upprepningar — se nedan.
data.order_idDin referens. Slå upp din order med detta.
data.amountVad köparen skickade i den här överföringen, i minsta enhet — till skillnad från API:et, som tar normala enheter.
data.feeVad vi tog, i minsta enhet.
data.creditedVad som hamnade på ditt saldo: amount − fee, i minsta enhet.
data.paid_minorTotalt mottaget på den här fakturan hittills, i minsta enhet. Fältet som spelar roll vid underpaid: statusen säger att mindre kom in, det här säger hur mycket mindre.
data.assetMyntet som faktiskt kom in. Inte nödvändigtvis myntet fakturan bad om.
data.asset_mismatchFinns, och är true, bara när myntet som kom in inte är fakturans mynt. Pengarna krediteras dig, men fakturan förblir obetald och status blir aldrig paid.
data.invoice_assetFöljer med asset_mismatch: myntet som fakturan faktiskt begär.
data.statusFakturans status just nu: pending, underpaid, paid, overpaid eller expired. Jämför mot vad du förväntade dig.
data.tx_hashTransaktionen på kedjan, för dina register och support.

Huvuden på varje leverans

http
X-Paysell-Event:      payment.credited
X-Paysell-Event-Id:   99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp:  1789000000
X-Paysell-Signature:  sha256=6f1c0e6a…
HuvudBetydelse
X-Paysell-EventHändelsetypen: payment.credited eller payment.rejected.
X-Paysell-Event-IdUnikt per händelse. Det är värdet du deduplicerar på.
X-Paysell-TimestampNär vi signerade, i unix-sekunder. Det ingår i den signerade strängen.
X-Paysell-Signaturesha256= följt av HMAC:en i hex. Se nedan.

Verifiera signaturen

Varje förfrågan signeras med webhook-hemligheten som visades en enda gång när du skapade nyckeln. Signaturen är HMAC-SHA256(secret, "{timestamp}.{raw_body}") — tidsstämpeln från X-Paysell-Timestamp, en bokstavlig punkt, sedan kroppens bytes. Kontrollera den innan du agerar: utan detta kan vem som helst som får reda på din URL ge dig en betald order.

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

Signera de råa kroppsbytena, exakt som de mottogs. Tolka JSON:en och serialisera om den och bytena ändras — nyckelordning, mellanslag — och signaturen matchar inte. Jämför i konstant tid (hmac.compare_digest, crypto.timingSafeEqual): ett vanligt == returnerar snabbare på en felaktig första byte, och den skillnaden räcker för att gissa en signatur en byte i taget.

Tidsstämpelfönstret

Avvisa allt vars tidsstämpel ligger mer än fem minuter från din egen klocka, åt något håll. Tidsstämpeln ligger inne i den signerade strängen just för att den inte ska gå att ändra utan att signaturen går sönder; fönstret är det som gör detta till ett skydd. Utan det förblir en förfrågan som fångats upp en gång giltig för alltid och kan spelas upp när som helst — signaturen ensam går aldrig ut. Håll serverns klocka på NTP, annars börjar den här kontrollen avvisa riktiga leveranser.

Dubbletter

Samma händelse kan komma mer än en gång. Det är inte en bugg: vi försöker igen tills du svarar 2xx, och en leverans som lyckades men vars svar aldrig nådde oss skickas igen. Registrera X-Paysell-Event-Id (det kommer också som event_id i kroppen) och se till att den andra ankomsten inte gör något.

Återförsök

Första försöket går ut så snart betalningen krediterats. Om det misslyckas — timeout, nekad anslutning, TLS-fel, en omdirigering, eller vilken status som helst utanför 2xx — försöker vi igen enligt ett fast schema:

1 min → 5 min → 15 min → 1 h → 6 h → 24 h

Sju försök totalt, utspridda över ungefär 31 timmar. De tidiga ligger tätt eftersom den vanliga orsaken är en mottagare som startade om och redan är tillbaka; de sena är glesa eftersom det inte hjälper någon att hamra på en server som varit nere ett dygn.

Efter sista försöket markeras leveransen dropped och vi slutar av oss själva. Den är inte förlorad: betalningsraden i ditt kontoområde visar tillståndet, antalet försök och felklassen, med en Skicka igen-knapp som startar en ny omgång av alla sju försöken. Din andra utväg är GET /api/merchant/v1/invoices/{invoice_id} — fakturan vet alltid sin egen status.

Hur en webhook-URL måste se ut

URL:en kontrolleras när du sparar den, och igen före varje enskild leverans. En URL som inte klarar kontrollen besvaras med 422 och code: "webhook_url_rejected" när du sparar, och markerar leveransen failed — utan återförsök — om den börjar fallera senare. Reglerna:

  • Bara `https://`, och port 443. En webhook bär betalningsuppgifter; i vanlig http är de läsbara för alla längs vägen.
  • Ett domännamn, inte en IP-adress. Du behöver ett certifikat ändå, och certifikat utfärdas inte för nakna IP-adresser.
  • Inget `localhost`, och inget .local-, .internal-, .corp-, .lan- eller .test-namn — våra servrar når inte ditt nät, och ett namn som slår upp inne i vårt är precis vad vi inte får anropa.
  • Inga inloggningsuppgifter i URL:en (https://user:pass@…). Lägg din egen token i sökvägen eller i en frågeparameter om du behöver en.
  • Varje adress namnet slår upp till måste vara publik — både A och AAAA. Privata, loopback-, link-local- och CGNAT-intervall avvisas, och kontrollen upprepas före varje leverans, så att peka om posten mot 127.0.0.1 i efterhand fungerar inte heller.
  • Omdirigeringar är ett misslyckande, inte ett hopp. Vi följer dem inte: adressen du gav oss är kontrollerad, den i ett Location-huvud är det inte.
Verifieringen sker två gånger med flit — en gång när du sparar URL:en, så att ett stavfel besvaras direkt i stället för med tyst utebliven leverans, och en gång före varje sändning, eftersom den som äger en domän kan peka om den mot en intern adress när som helst. Om din endpoint flyttar, uppdatera nyckeln först: en avvisad URL levererar ingenting och köar inte.

Svara snabbt

Vilken 2xx som helst duger, inom tio sekunder — det är hela vår timeout, uppkopplingen inräknad. Svara först, gör det långsamma arbetet efteråt; en endpoint som väntar på sin egen databas innan den svarar registreras förr eller senare som en timeout och görs om, och du behandlar samma händelse två gånger. Allt annat — en 4xx, en 5xx, en omdirigering, en hängning — räknas som ett misslyckat försök och går tillbaka in i schemat ovan.

Leverans, ärligt talat

Det som garanteras är leveransmekanismen: sju försök över ungefär 31 timmar, en manuell omsändning från ditt kontoområde, och en faktura-endpoint som alltid vet det verkliga tillståndet. Bygg flödet så att en webhook som aldrig kommer fram inte kostar dig något — läs fakturan på din tacksida, eller stäm av öppna fakturor en gång i timmen. Webhookar är den snabba vägen, inte den enda.

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.

Statusreferens#

Varje faktura- och betalningsstatus, förklarad.

Faktura

StatusBetydelseVad du ska göra
pendingVäntar på betalning.Håll ordern öppen.
paidBetald i sin helhet.Släpp varorna.
overpaidMer kom in än begärt. Överskottet krediteras dig i sin helhet.Släpp varorna; återbetala mellanskillnaden om du vill.
underpaidMindre kom in än begärt. Fakturan förblir öppen och behåller sin adress: köparen kan fylla på till samma ställe, och paid_minor säger hur mycket som redan kommit in. Den går att betala resten av sin livstid plus 24 timmars respit efter expires_at.Vänta på påfyllningen, eller gör upp med kunden. Släpp inte varorna — fakturan är inte betald.
expiredFönstret stängdes, respiten inräknad. Kan fortfarande bära pengar: det som kom in blev kvar på ditt saldo, och paid_minor säger hur mycket.Erbjud en ny faktura. Acceptera inte betalning till den gamla adressen: när en faktura förfaller går adressen tillbaka in i poolen, och en mycket sen överföring blir ett supportärende snarare än en automatisk kreditering. Kontrollera paid_minor innan du säger till kunden att inget kommit in.
cancelledAvbruten av dig. Adressen släpps tillbaka till poolen.Inget.

Betalning

Synlig i ditt kontoområde; användbar när du stöttar en kund mitt i en betalning.

StatusBetydelse
detectedSedd på kedjan, väntar på bekräftelser.
confirmedNätverket bekräftade den. Kreditering härnäst.
creditedPå ditt saldo. Det är då webhooken utlöses.
reviewHålls för en extra kontroll — till exempel mynt som kommer till en adress utan öppen faktura.
rejectedInte krediterad. Anledningen är registrerad.

När en betalning går till `review`

Vissa insättningar hålls kvar för en extra kontroll i stället för att krediteras direkt: en ovanligt stor summa, mynt som kommer till en adress utan öppen faktura, eller att de två blockkedjekällor vi frågar är oense om vad som hänt. Inget går förlorat — pengarna väntar på ett beslut, och webhooken utlöses så snart det finns ett, vilket kan dröja minuter eller timmar. Behandla ett uteblivet anrop för en betalning som visas som review som normalt snarare än som ett fel. Om det spelar roll för en order, fråga supporten och uppge tx_hash.

Belopp#

Normala enheter ut, minsta enheter tillbaka.

Skicka belopp i myntets normala enheter, som sträng"1.5" är en och en halv. Inte ett JSON-tal och inte den minsta enheten.

TillgångDecimalerDu skickaramount_minor i svaret
TON9"1.5""1500000000"
USDT_TON6"1.5""1500000"

En sträng och inte ett tal, eftersom JSON-tal är IEEE-754-doubles och ett stort belopp i nanoton slutar rymmas exakt i ett sådant. Fler decimaler än myntet har ger 422, aldrig en tyst avrundning av dina pengar. Webhookar går åt andra hållet: där är amount, fee och credited heltal i minsta enhet, för den sidan läses av kod, inte av en människa.

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, maximum, och hastighetsgränser.

GränsVärdeVid överträdelse
Minsta faktura0.1 TON · 3 USDT422
Största faktura7000 TON · 10000 USDT422
Fakturor per timme, per butik60429
Öppna fakturor samtidigt20, växer med varje betald faktura, upp till 200429
Fakturans livstid1 minut – 24 timmar (standard 2 timmar)422
API-förfrågningar per nyckel120 per minut429 + Retry-After

Minimumet är inte byråkrati. Vår avgift är procentuell, men att ta emot en betalning kostar ett fast belopp: att flytta USDT från en mottagaradress innebär att först fylla den med gas, ur vår egen ficka. Under några dollar täcker inte avgiften hanteringen, och att acceptera en sådan betalning skulle innebära att kreditera dig pengar som är olönsamma att flytta.

Maxgränsen handlar inte om stora handlare — den är en fälla för ett enhetsmisstag. Skicka "5000000" där du menade "5" och du skulle annars få en faktura på fem miljoner dollar: köparen ser en absurd summa och går. En riktig order når aldrig detta tak; ett misstag gör det alltid. Båda taken är inställningar (invoice_max_ton, invoice_max_usdt) och kan höjas för din butik — fråga oss.

Timgränsen och gränsen för öppna fakturor skyddar båda adresspoolen. Varje öppen faktura upptar en mottagaradress, och en okontrollerad loop på en sajt skulle annars tömma poolen för alla. En ny butik får ha 20 fakturor öppna samtidigt; utrymmet växer med en för varje faktura den faktiskt fått betald, upp till ett tak på 200. underpaid räknas som öppen — den håller fortfarande sin adress och väntar på resten. Att avbryta en övergiven faktura lämnar tillbaka adressen omedelbart. Återförsök med samma idempotency_key räknas inte mot timgränsen.

Förfrågningsgränsen är 120 per minut per API-nyckel — två anrop i sekunden, långt över vilket verkligt orderflöde som helst. En 429 bär ett Retry-After-huvud i sekunder: vänta så länge i stället för att göra om i en tight loop, vilket bara skjuter fönstret längre fram.

Fel#

Statuskoderna du faktiskt kommer att se.

Fel kommer tillbaka som JSON, i två former. Allt som vi eller bearbetningskärnan avgör lägger ett {code, message}-par under detail. En förfrågningskropp som inte klarar valideringen lägger i stället en lista med fältfel där. Kontrollera vilken av dem du fick innan du läser detail.code — och förgrena på `code`, aldrig på `message`: formuleringen kan ändras när som helst, koden inte.

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ärVad du ska göra
401Nyckel saknas, felaktig, eller återkallad.Kontrollera huvudet. Utfärda en ny nyckel om den återkallades.
404Ingen sådan faktura, eller så tillhör den en annan butik.Kontrollera id:t. De två fallen svarar likadant med flit, så att ett id inte kan sonderas.
409Fakturan är i ett tillstånd som förbjuder detta.Läs dess nuvarande status först.
422Förfrågan är felformad, eller beloppet ligger utanför fakturans gränser.Meddelandet namnger både det skickade värdet och gränsen.
429För många fakturor den här timmen, för många öppna samtidigt, eller för många förfrågningar.Vänta ut Retry-After och försök igen.
502Vi kunde inte nå bearbetningskärnan.Försök igen med samma idempotensnyckel.

Koder

Den form vi själva avgör är {"detail": {"code": …, "message": …}}. Det här är koderna som merchant-API:et returnerar.

KodStatusBetydelse
invalid_api_key401Nyckeln saknas, är felaktig, okänd eller återkallad. Alla fyra svarar likadant, så en nyckel kan inte sonderas.
not_found404Inget sådant objekt, eller så tillhör det en annan butik.
invalid_input422Förfrågan klarade inte valideringen i kärnan — ett felaktigt belopp, för många decimaler, ett belopp utanför fakturans gränser.
conflict409Åtgärden motsäger nuvarande tillstånd, till exempel att avbryta en faktura som inte längre är öppen.
too_many_requests429En hastighetsgräns: fakturor per timme, öppna fakturor, eller förfrågningar per minut. Retry-After säger hur länge du ska vänta.
cbc_unreachable502Vi kunde inte nå bearbetningskärnan. Försök igen med samma idempotency_key.
webhook_url_rejected422Bara när en nyckel sparas: webhook-URL:en klarade inte kontrollerna ovan. detail.reason namnger vilken regel — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials, och så vidare.

Ett 502 betyder inte att fakturan inte skapades — förfrågan kan ha gått igenom med svaret förlorat på vägen tillbaka. Försök igen med samma idempotency_key och du får antingen den befintliga fakturan eller en ny, aldrig två.

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.

Återbetalningar#

Så återbetalar du en kund.

Återbetalningar går via supporten, inte via ett API-anrop. En återbetalning är en ny överföring till en adress som en människa uppgett, och en betalleverantör som skickar tillbaka pengar automatiskt på ett API-anrop är en betalleverantör som kan förmås att skicka pengar till en angripares adress. Därför är det medvetet manuellt.

För att återbetala en köpare, öppna ett supportärende från ditt kontoområde med invoice_id eller tx_hash, beloppet, och adressen att skicka till. En operatör kontrollerar betalningen, flyttar pengarna ut från ditt saldo, och svarar i samma ärende. Räkna med en arbetsdag, inte en minut.

Två följder värda att bygga för. Överbetalning krediteras dig i sin helhet — vi behåller inget av den — så att lämna tillbaka mellanskillnaden till en köpare som skickat för mycket är ditt beslut, och går samma väg. Och en underbetald faktura är inget återbetalningsfall så länge den är öppen: pengarna ligger på ditt saldo, adressen bevakas fortfarande, och köparen kan helt enkelt fylla på. Först efter respittiden, när fakturan går till expired med pengar på sig, finns det ett beslut att fatta.

Testning#

Så testar du din integration före lansering.

Nycklarna här är skarpa: varje nyckel som utfärdas är en sk_live_-nyckel mot produktionskärnan och TON-mainnet. Det finns ingen separat testmiljö, och det har en fördel: du kör exakt den väg dina riktiga ordrar kommer att ta.

Så testa som du skulle testa vad som helst som rör riktiga pengar: på små belopp. Skapa en faktura på minimibeloppet (0.1 TON eller 3 USDT), betala den från din egen plånbok, och följ hela vägen — betalsidan, webhooken, signaturkontrollen, din order som slår om till betald. Avgiften tas ut, och mynten flyttas på riktigt.

Det du kan öva på utan att spendera något: att skapa och läsa en faktura, att avbryta en, 422 på ett felformat belopp, 401 på fel nyckel, och din egen signaturverifiering — signera en exempelkropp med din hemlighet och mata din egen hanterare med den. Det som verkligen kräver en riktig betalning är bara sista steget: en faktisk payment.credited-webhook.

Planera integrationen så att den inte är beroende av en sandlåda eller en simulerad betalning: den skarpa vägen går snabbare — och sannare — att verifiera.

Behandla din första skarpa order som det verkliga testet: välj ett litet belopp, håll fakturan öppen i kontrollpanelen, och kontrollera betalningsraden och webhookens tillstånd innan du pekar riktiga 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.

Checklista inför lansering#

Tio saker att kontrollera före lansering.

  • Nyckeln är endast serversidan, aldrig i webbläsarens JavaScript.
  • Webhook-signaturen verifieras mot "{timestamp}.{raw_body}", i konstant tid.
  • Leveranser äldre än fem minuter avvisas, och serverns klocka går på NTP.
  • Ett upprepat X-Paysell-Event-Id gör ingenting andra gången.
  • Webhooken svarar 2xx inom tio sekunder; långsamt arbete sker efteråt.
  • Webhook-URL:en är en https://-domän på port 443, utan omdirigering framför sig.
  • En missad webhook går att överleva: faktura-endpointen läses på tacksidan eller vid en avstämningsgenomgång.
  • idempotency_key genereras en gång per order och återanvänds vid återförsök.
  • Belopp går ut i normala enheter som strängar; webhookens siffror läses som minsta enheter.
  • Adressen visas exakt som den returnerades, oförändrad.
  • overpaid och underpaid hanteras, inte bara paid; expired kan fortfarande bära paid_minor.
  • Varan släpps vid status: paid eller overpaid, aldrig för att anropet bara kommit fram.
  • 429 hanteras genom att vänta ut Retry-After, inte genom att försöka igen direkt.
  • Saldon läses från oss, inte spåras separat som sanning.

Något oklart?

Om den här sidan inte besvarade din fråga är det en lucka i dokumentationen och värt att berätta för oss om. Skriv från ditt kontoområde så fixar vi sidan, inte bara svaret.