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.
Så fungerar en betalning#
Sex steg, de flesta våra.
Sex steg, de flesta våra:
- 1
Din kund klickar på betala
Din server anropar vårt API med beloppet och din egen orderreferens.
- 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
Kunden skickar mynt
De skannar QR-koden eller kopierar adressen. Skicka dem till den
payment_urlvi returnerar så hanteras sidan åt dig — belopp, adress, QR, nedräkning, status i realtid. - 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
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
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.
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.
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
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
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
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
Skicka köparen till
payment_urlDet ä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
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ärdata.statusärpaidelleroverpaid. 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
- Write the webhook receiver — Webhooks and A complete receiver. Nothing else on this page matters as much: it is what turns a payment into a paid order.
- Handle
underpaidandoverpaid, not justpaid— see Status reference. - Read Typical mistakes, then walk the go-live checklist before you point real customers at it.
Autentisering#
Din API-nyckel, och hur den används.
Varje förfrågan bär din nyckel i Authorization-huvudet:
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxAVarje 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.
Endpoints at a glance#
Four calls, three of them authenticated.
This is the whole merchant API. Balances, payouts and history are not in it — they live in your account area, where a person is looking at them.
| Endpoint | Method | Auth | What it does |
|---|---|---|---|
| /invoices | POST | API key | Open an invoice and get a payment link. Details. |
| /invoices/{invoice_id} | GET | API key | Read one invoice's current state. Details. |
| /invoices/{invoice_id}/cancel | POST | API key | Close an invoice that is still open and free its address. Details. |
| /public/invoices/{invoice_id} | GET | none | What the hosted checkout page reads. Only needed if you build your own. Details. |
Every path is relative to https://paysell.me/api/merchant/v1. There is no list endpoint and no refund endpoint — see Refunds.
Skapa en faktura#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoicesFörfrågningskropp
| Fält | Typ | Obligatoriskt | Beskrivning |
|---|---|---|---|
| asset | string | ja | Antingen TON eller USDT_TON. |
| amount | string | ja | Myntets normala enheter, som sträng: "5" är 5 USDT. Inte fler decimaler än myntet har. Se Belopp. |
| order_id | string | nej | Din egen referens, upp till 200 tecken. Kommer tillbaka i varje webhook — så här matchar du en betalning mot en order. |
| description | string | nej | Upp till 1000 tecken. Visas för köparen på betalningssidan. |
| ttl_minutes | number | nej | Hur länge fakturan förblir betalbar, i minuter. 1–1440; utelämna det så gäller standardvärdet — 2 timmar i dag. |
| idempotency_key | string | nej | Upp 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
{
"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ält | Vad du ska göra med det |
|---|---|
| invoice_id | Spara det mot din order. Det är vad som identifierar betalningen överallt annars. |
| payment_url | Omdirigera köparen hit. Inget annat att bygga. |
| address | Endast om du renderar din egen kassa. Visa den exakt som den gavs — se varningen nedan. |
| amount | Beloppet i normala enheter, precis som du skickade det. Visa detta. |
| amount_minor | Samma belopp som heltal i minsta enhet. Räkna med detta. |
| expires_at | Visa en nedräkning. Efter den passerat slutar adressen att bevakas för denna faktura. |
| status | Alltid pending här. Verkliga ändringar kommer via webhook. |
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}
/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
/api/merchant/v1/invoices/{invoice_id}/cancelStä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ändelse | När | Vad 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.rejected | En 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
{
"event_id": "99f74f58-efbb-4af1-b0a3-76b0073f9e6b",
"type": "payment.credited",
"data": {
"invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
"order_id": "order-1042",
"asset": "USDT_TON",
"amount": "5000000",
"credited": "4905000",
"fee": "95000",
"status": "paid",
"paid_minor": "5000000",
"tx_hash": "97a1f0…"
}
}{
"event_id": "0a1b2c3d-4e5f-4a6b-8c9d-0e1f2a3b4c5d",
"type": "payment.rejected",
"data": {
"invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
"order_id": "order-1042",
"asset": "USDT_TON",
"amount": "5000000",
"tx_hash": "97a1f0…",
"reason": "could not be matched to any order"
}
}Fältmappning
| Fält | Betydelse |
|---|---|
| event_id | Unikt per händelse; finns också i huvudet X-Paysell-Event-Id. Spara det och ignorera upprepningar — se nedan. |
| data.order_id | Din referens. Slå upp din order med detta. |
| data.amount | Vad köparen skickade i den här överföringen, i minsta enhet — till skillnad från API:et, som tar normala enheter. |
| data.fee | Vad vi tog, i minsta enhet. |
| data.credited | Vad som hamnade på ditt saldo: amount − fee, i minsta enhet. |
| data.paid_minor | Totalt 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.asset | Myntet som faktiskt kom in. Inte nödvändigtvis myntet fakturan bad om. |
| data.asset_mismatch | Finns, 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_asset | Följer med asset_mismatch: myntet som fakturan faktiskt begär. |
| data.status | Fakturans status just nu: pending, underpaid, paid, overpaid eller expired. Jämför mot vad du förväntade dig. |
| data.tx_hash | Transaktionen på kedjan, för dina register och support. |
Huvuden på varje leverans
X-Paysell-Event: payment.credited
X-Paysell-Event-Id: 99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp: 1789000000
X-Paysell-Signature: sha256=6f1c0e6a…| Huvud | Betydelse |
|---|---|
| X-Paysell-Event | Händelsetypen: payment.credited eller payment.rejected. |
| X-Paysell-Event-Id | Unikt per händelse. Det är värdet du deduplicerar på. |
| X-Paysell-Timestamp | När vi signerade, i unix-sekunder. Det ingår i den signerade strängen. |
| X-Paysell-Signature | sha256= 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:
import hmac, hashlib, time
def is_ours(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
if abs(time.time() - int(timestamp)) > 300: # ±5 minutes
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
# compare_digest, not ==: a plain comparison leaks the answer through timing
return hmac.compare_digest("sha256=" + expected, signature)Node.js:
const crypto = require("node:crypto")
function isOurs(body, signature, timestamp, secret) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false // ±5 min
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(timestamp + ".").update(body) // body is the raw Buffer, not a parsed object
.digest("hex")
const a = Buffer.from(expected), b = Buffer.from(signature)
// timingSafeEqual throws when the lengths differ, so check that first
return a.length === b.length && crypto.timingSafeEqual(a, b)
}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 hSju 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.1i 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.
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.
const express = require("express")
const crypto = require("node:crypto")
const app = express()
const SECRET = process.env.PAYSELL_WEBHOOK_SECRET
function isOurs(body, signature, timestamp) {
const sentAt = Number(timestamp)
if (!Number.isFinite(sentAt)) return false
if (Math.abs(Date.now() / 1000 - sentAt) > 300) return false // ±5 minutes
const expected = "sha256=" + crypto
.createHmac("sha256", SECRET)
.update(timestamp + ".").update(body) // raw Buffer, not a parsed object
.digest("hex")
const a = Buffer.from(expected), b = Buffer.from(signature ?? "")
return a.length === b.length && crypto.timingSafeEqual(a, b)
}
app.post(
"/paysell/webhook",
express.raw({ type: "application/json" }), // NOT express.json()
async (req, res) => {
const signature = req.get("X-Paysell-Signature")
const timestamp = req.get("X-Paysell-Timestamp")
if (!isOurs(req.body, signature, timestamp)) return res.sendStatus(401)
const event = JSON.parse(req.body.toString("utf8"))
// Answer first: 10 seconds is the whole timeout, connection included.
res.sendStatus(200)
// Deduplicate. In real code this is a unique column, not a Set.
if (await alreadyHandled(event.event_id)) return
await remember(event.event_id)
if (event.type !== "payment.credited") return
const { order_id, status, credited, asset, tx_hash } = event.data
// The only condition that may release the goods.
if (status !== "paid" && status !== "overpaid") return
await markOrderPaid(order_id, { credited, asset, tx_hash })
}
)Python with Flask. request.get_data() is the raw body; request.form and request.json are not.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["PAYSELL_WEBHOOK_SECRET"]
def is_ours(body: bytes, signature: str, timestamp: str) -> bool:
try:
sent_at = int(timestamp)
except (TypeError, ValueError):
return False
if abs(time.time() - sent_at) > 300: # ±5 minutes
return False
signed = timestamp.encode() + b"." + body
expected = hmac.new(SECRET.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest("sha256=" + expected, signature)
@app.post("/paysell/webhook")
def paysell_webhook():
body = request.get_data() # raw bytes, unparsed
if not is_ours(body, request.headers.get("X-Paysell-Signature", ""),
request.headers.get("X-Paysell-Timestamp", "")):
return "", 401
event = json.loads(body)
# Deduplicate. In real code this is a unique column, not a set.
if already_handled(event["event_id"]):
return "", 200 # a repeat is still a success
remember(event["event_id"])
if event["type"] == "payment.credited":
data = event["data"]
# The only condition that may release the goods.
if data["status"] in ("paid", "overpaid"):
mark_order_paid(data["order_id"], data)
return "", 200 # 2xx within 10 secondsWhat the code is doing, and why
- Verify before anything acts on the body. An unsigned request that reaches your business logic is a paid order for whoever found your URL.
- Answer 2xx first, work afterwards. Ten seconds is the whole timeout, connection included. A handler that waits for its own database gets recorded as a timeout and retried, and you process the same event twice.
- Deduplicate on `event_id` in storage that survives a restart. The in-memory set in the examples keeps them short; a real one is a unique column in your database.
- Mark the order paid only on `status: paid` or `overpaid`.
underpaidmeans part of the money arrived and the invoice is still open, and a deposit in the wrong coin never makes an invoice paid either. - Answer 2xx to a duplicate too. A repeat that gets a 4xx looks like a failure to us and comes back again on the schedule.
payment.rejected arrives at the same endpoint. It means a deposit held for an additional check was declined and the money will not be credited: release nothing, and if the invoice was already paid by an earlier transfer, this event is about the extra deposit, not about that payment.
Statusreferens#
Varje faktura- och betalningsstatus, förklarad.
Faktura
| Status | Betydelse | Vad du ska göra |
|---|---|---|
| pending | Väntar på betalning. | Håll ordern öppen. |
| paid | Betald i sin helhet. | Släpp varorna. |
| overpaid | Mer kom in än begärt. Överskottet krediteras dig i sin helhet. | Släpp varorna; återbetala mellanskillnaden om du vill. |
| underpaid | Mindre 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. |
| expired | Fö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. |
| cancelled | Avbruten 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.
| Status | Betydelse |
|---|---|
| detected | Sedd på kedjan, väntar på bekräftelser. |
| confirmed | Nätverket bekräftade den. Kreditering härnäst. |
| credited | På ditt saldo. Det är då webhooken utlöses. |
| review | Hålls för en extra kontroll — till exempel mynt som kommer till en adress utan öppen faktura. |
| rejected | Inte 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ång | Decimaler | Du skickar | amount_minor i svaret |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "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.
// Send amounts in the coin's normal units, as a string:
const amount = "1.5" // one and a half TON or USDT
// In responses, amount is that same human string; amount_minor is the
// integer in smallest units — use it for exact maths, as a string or BigInt:
BigInt(invoice.amount_minor) // e.g. 1500000nGränser#
Minimum, maximum, och hastighetsgränser.
| Gräns | Värde | Vid överträdelse |
|---|---|---|
| Minsta faktura | 0.1 TON · 3 USDT | 422 |
| Största faktura | 7000 TON · 10000 USDT | 422 |
| Fakturor per timme, per butik | 60 | 429 |
| Öppna fakturor samtidigt | 20, växer med varje betald faktura, upp till 200 | 429 |
| Fakturans livstid | 1 minut – 24 timmar (standard 2 timmar) | 422 |
| API-förfrågningar per nyckel | 120 per minut | 429 + 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.
{
"detail": {
"code": "invalid_input",
"message": "invoice amount below the minimum: 0.010000 USDT_TON, minimum 3.000000 USDT_TON"
}
}{
"detail": [
{
"type": "string_type",
"loc": ["body", "amount"],
"msg": "Input should be a valid string",
"input": 5
}
]
}| Status | När | Vad du ska göra |
|---|---|---|
| 401 | Nyckel saknas, felaktig, eller återkallad. | Kontrollera huvudet. Utfärda en ny nyckel om den återkallades. |
| 404 | Ingen 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. |
| 409 | Fakturan är i ett tillstånd som förbjuder detta. | Läs dess nuvarande status först. |
| 422 | Förfrågan är felformad, eller beloppet ligger utanför fakturans gränser. | Meddelandet namnger både det skickade värdet och gränsen. |
| 429 | Fö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. |
| 502 | Vi 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.
| Kod | Status | Betydelse |
|---|---|---|
| invalid_api_key | 401 | Nyckeln saknas, är felaktig, okänd eller återkallad. Alla fyra svarar likadant, så en nyckel kan inte sonderas. |
| not_found | 404 | Inget sådant objekt, eller så tillhör det en annan butik. |
| invalid_input | 422 | Förfrågan klarade inte valideringen i kärnan — ett felaktigt belopp, för många decimaler, ett belopp utanför fakturans gränser. |
| conflict | 409 | Åtgärden motsäger nuvarande tillstånd, till exempel att avbryta en faktura som inte längre är öppen. |
| too_many_requests | 429 | En 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_unreachable | 502 | Vi kunde inte nå bearbetningskärnan. Försök igen med samma idempotency_key. |
| webhook_url_rejected | 422 | Bara 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 isunderpaid. - The invoice's short id with a copy button, so a buyer can quote it to your support.
- Wallet buttons: Tonkeeper and MyTonWallet open with the address and the amount already filled in. Other reveals a QR code and the address with a copy button.
- A warning that only this invoice's coin, on the TON network, may be sent — anything else is lost.
How the page reacts
| Invoice | What the buyer sees |
|---|---|
| pending | “Waiting for payment”, with the wallet choices and the countdown. The page re-reads the invoice every five seconds. |
| underpaid | “Received X of Y”, the exact remainder still owed, and the same address to send it to. The wallet link is prefilled with what is missing, not with the original total — otherwise the buyer would pay twice. |
| paid · overpaid | “Payment received”, and a button back to your shop if the shop has a URL. |
| expired | “Payment window closed”. If money did arrive, the amount is named with a note to contact you — silence here would send the buyer looking for their coins. |
| cancelled | “Payment cancelled”, with a link back to your shop. |
If you build your own
You gain your own branding and take on all of the above: the exact address string, the right coin, the countdown with its grace period, the underpayment case, and polling. GET /api/merchant/v1/public/invoices/{invoice_id} is the same unauthenticated read the hosted page uses — rate limited per IP, so poll it no more often than every few seconds. Print the address exactly as returned.
Typical integration mistakes#
The handful that account for most broken integrations.
None of these are exotic. Every one of them has cost somebody a day.
Sending the amount as a number
{"amount": 5}is a422. It has to be the string"5": JSON numbers are IEEE-754 doubles, and a large sum in nanotons stops being exactly representable in one.Sending the smallest unit
"5000000"for 5 USDT is a mistake in units, and the upper invoice limit exists to catch it. Smallest units are what comes back in webhooks, not what goes out in requests.Treating the webhook's arrival as payment
Read
data.status.underpaidis not paid, and a deposit in a coin the invoice did not ask for never makes it paid either. Release the goods onpaidoroverpaid, on nothing else.Not checking the timestamp
A signature on its own never expires. Without the ±5 minute window on
X-Paysell-Timestamp, a delivery captured once can be replayed at any time and will still verify.Verifying the signature over re-serialised JSON
Parse the body and serialise it again and the bytes change — key order, spacing — and the HMAC no longer matches. Sign the raw bytes exactly as received.
No deduplication
The same
event_idwill arrive twice sooner or later: we retry until you answer 2xx, and a response lost on the way back looks like a failure from here. The second arrival must do nothing.Using the
Idempotency-KeyheaderThis API reads
idempotency_keyfrom the request body; the header is not read at all. A retry without the field opens a second invoice for the same order.Assuming
detailis always an objectIt is
{code, message}for anything we or the core decide, and a list of field errors when the body itself fails validation. Check which one you got before readingdetail.code.
Å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.
For AI agents and LLMs#
Machine-readable copies of this page, and a prompt to start from.
Everything on this page also exists in a form a model can read directly. Point your assistant at one of these instead of pasting screenshots of documentation into a chat.
The three files
| File | What it is | Use it for |
|---|---|---|
| /llms-full.txt | The whole documentation as one markdown file: endpoints, fields, statuses, limits, errors, webhooks with working verification code, the fee, the checkout page, the checklist. | Pasting into a model's context, or letting an agent fetch it. Start here. |
| /llms.txt | A short index in the llms.txt format: what Paysell is, the five rules that decide whether an integration works, and links to everything else. | Letting an agent discover the rest on its own. |
| /openapi.json | OpenAPI 3.1, generated from the running application's own models, both webhook events included. | Generating a client, or loading into anything that speaks OpenAPI. |
A prompt to start from
Copy this, replace the stack, and hand it to your assistant. It names the four things that go wrong most often, so the answer does not have to be corrected afterwards.
Read https://paysell.me/llms-full.txt and implement Paysell payments in my <stack>:
create invoices (POST /api/merchant/v1/invoices, Bearer sk_live_ key, amount as a
decimal string in normal units), redirect the buyer to payment_url, verify webhook
signatures (HMAC-SHA256 over "{timestamp}.{raw_body}", header X-Paysell-Signature,
reject anything whose X-Paysell-Timestamp is more than 300 seconds off), deduplicate
by event_id, answer 2xx within 10 seconds, and mark orders paid only on a
payment.credited event whose data.status is "paid" or "overpaid".Feeding it to a specific tool
- Agents with web access — Claude Code, Cursor, Windsurf and the like: give them the
/llms-full.txtlink. One fetch, no setup. - A chat window — ChatGPT, Claude, Gemini: paste the contents of
/llms-full.txtinto the conversation or attach it as a file. It is written to fit in one message. - OpenAPI tooling — client generators, Postman, an agent's tool schema: point it at
https://paysell.me/openapi.json. Itsserversentry already carries the production base URL, so generated calls go to the right place.
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-Idgö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_keygenereras 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.
overpaidochunderpaidhanteras, inte barapaid;expiredkan fortfarande bärapaid_minor.- Varan släpps vid
status: paidelleroverpaid, aldrig för att anropet bara kommit fram. 429hanteras genom att vänta utRetry-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.