Vastaanota kryptomaksuja
Paysell selvittää TON- ja USDT-maksut TON-verkossa. Luot laskun, me annamme sinulle linkin, ja saat allekirjoitetun callbackin, kun rahat on vahvistettu ketjussa ja hyvitetty saldollesi.
Yleiskatsaus#
Mitä Paysell tekee, ja mitä ei.
Paysell on maksunvälittäjä, ei lompakko. Et koskaan käsittele yksityisiä avaimia, seuraa lohkoketjua tai päätä, milloin transaktio on lopullinen — sen hoidamme me.
Jokainen lasku saa oman vastaanotto-osoitteensa. Kun ostaja maksaa sen, odotamme verkon vahvistavan siirron, vähennämme palkkiomme ja hyvitämme lopun saldollesi. Voit nostaa varoja mille tahansa osoitteelle.
Miten maksu toimii#
Kuusi vaihetta, useimmat niistä meidän.
Kuusi vaihetta, useimmat niistä meidän:
- 1
Asiakkaasi klikkaa maksa
Palvelimesi kutsuu API:tamme summalla ja omalla tilausviitteelläsi.
- 2
Annamme osoitteen
Tuore vastaanotto-osoite otetaan ennalta generoidusta joukosta ja sidotaan tähän laskuun. Yksi osoite kuuluu tasan yhteen avoimeen laskuun, ja näin maksu yhdistetään siihen.
- 3
Asiakas lähettää kolikoita
Hän skannaa QR-koodin tai kopioi osoitteen. Lähetä ne osoitteeseen
payment_url, jonka palautamme, ja sivu hoidetaan puolestasi — summa, osoite, QR-koodi, lähtölaskenta, reaaliaikainen tila. - 4
Havaitsemme siirron
Kahta riippumatonta lohkoketjudatan lähdettä kysytään, ja niiden vastauksia verrataan. Jos ne eivät täsmää, pysähdymme sen sijaan, että valitsisimme kätevämmän vastauksen.
- 5
Odotamme lopullisuutta
Sisällyttäminen masterchainiin plus kolme lohkoa päälle. Suunnilleen viisitoista sekuntia — maksu, joka näyttää selvitetyltä mutta myöhemmin katoaa, olisi sinun tappiosi, joten emme ota sitä riskiä.
- 6
Hyvitetty, ja sinulle ilmoitetaan
Palkkio vähennetään, loppu päätyy saldollesi, ja allekirjoitettu webhook lähtee palvelimellesi mukanaan
order_id-tunnuksesi.
Noin minuutti maksusta callbackiin: noin viisitoista sekuntia verkon vahvistuksiin, loput on meidän valvottujen osoitteiden läpikäyntimme.
Minne rahat menevät#
Palkkio, ja mistä se lasketaan.
Palkkio on 0,2 %, kiinteä kaupallesi rekisteröintihetkellä. Jos vakiotaksa muuttuu myöhemmin, sinun ei muutu — se kirjataan jokaiseen laskuun lukuna, ei viittauksena asetukseen.
Palkkio otetaan siitä, mitä todella saapuu, ei siitä, mitä lasku pyysi. Laskuta 5 USDT ja saa 20, ja palkkio lasketaan 20:stä. Alimaksa, ja se lasketaan siitä, mikä saapui.
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 USDTLiikamaksu hyvitetään kokonaisuudessaan — emme pidä erotusta. Alimaksu jättää laskun avoimeksi, jotta ostaja voi täydentää sen samaan osoitteeseen.
Pika-aloitus#
Viisi minuuttia ensimmäiseen laskuusi.
Viisi vaihetta. Kaksi on klikkauksia tilialueellasi, yksi on yksittäinen pyyntö palvelimeltasi, ja kaksi viimeistä tapahtuu itsestään.
- 1
Luo kauppa
Tilialueellasi. Se alkaa vastaanottaa maksuja välittömästi — ilman tarkastuksen odottamista. Vahvistus tapahtuu hiljaa taustalla ja rajoittaa vain nostoja, ei saapuvia maksuja.
- 2
Luo API-avain
Kauppasi → API-avaimet → Uusi avain. Avain ja webhook-salaisuus näytetään kerran eikä koskaan uudelleen. Säilytä niitä kuten säilyttäisit tietokannan salasanaa, äläkä koskaan lähetä niitä selaimeen.
- 3
Luo lasku
Yksi pyyntö palvelimeltasi, yksi linkki takaisin. Kaikki neljä alla olevaa koodinpätkää lähettävät täsmälleen saman asian.
- 4
Lähetä ostaja osoitteeseen
payment_urlSe on koko kassa — summa, osoite, QR-koodi, lähtölaskenta, reaaliaikainen tila — eikä mitään tarvitse rakentaa. Katso Kassa, mitä ostaja todella näkee.
- 5
Odota webhookia
Kun raha on vahvistettu ketjussa ja hyvitetty, teemme palvelimellesi POST-kutsun allekirjoitetulla
payment.credited-tapahtumalla. Todenna allekirjoitus ja merkitse sitten tilaus maksetuksi — mutta vain kundata.statusonpaidtaioverpaid. Katso Webhookit.
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"
}'Ohjaa ostaja vastauksessa olevaan payment_url-osoitteeseen. Olet valmis — loput saapuu webhookina.
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.
Todennus#
API-avaimesi, ja miten sitä käytetään.
Jokainen pyyntö kantaa avaimesi Authorization-otsikossa:
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxAJokainen täällä myönnetty avain alkaa sk_live_-etuliitteellä. sk_test_-etuliite on olemassa vain testiverkkoon osoittavassa asennuksessa, eikä sellaista asennusta tarjota — katso Testaus. Tallennamme yksisuuntaisen tiivisteen, emme itse avainta, joten kukaan, meidät mukaan lukien, ei voi näyttää sitä sinulle uudelleen. Hukkasitko sen? Luo uusi ja peru vanha.
Kauppa johdetaan avaimesta, minkä vuoksi mikään pyyntö ei koskaan ota kaupan tunnusta. Avain voi toimia vain omassa kaupassaan.
Polussa on versio: /api/merchant/v1/…. Version sisällä vain lisäämme kenttiä — mitään ei nimetä uudelleen eikä mikään vaihda merkitystään hiljaa. Muutos, joka rikkoisi koodisi, saa uuden etuliitteen /v2, ja /v1 toimii ilmoitetun ajan.
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.
Luo lasku#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoicesPyynnön runko
| Kenttä | Tyyppi | Pakollinen | Kuvaus |
|---|---|---|---|
| asset | string | kyllä | Joko TON tai USDT_TON. |
| amount | string | kyllä | Kolikon normaaliyksiköt merkkijonona: "5" on 5 USDT. Ei enempää desimaaleja kuin kolikolla on. Katso Summat. |
| order_id | string | ei | Oma viitteesi, enintään 200 merkkiä. Palautuu jokaisessa webhookissa — näin yhdistät maksun tilaukseen. |
| description | string | ei | Enintään 1000 merkkiä. Näytetään ostajalle maksusivulla. |
| ttl_minutes | number | ei | Kuinka kauan lasku pysyy maksettavana, minuutteina. 1–1440; jätä pois, niin oletus pätee — tällä hetkellä 2 tuntia. |
| idempotency_key | string | ei | Enintään 200 merkkiä. Lähetä sama arvo uudelleenyrityksessä, niin saat saman laskun takaisin toisen sijaan. Rungon kenttä, ei Idempotency-Key-otsikko — sitä otsikkoa ei lueta täällä. |
Vastaus · 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"
}Kenttien yhdistäminen tilaukseesi
| Kenttä | Mitä sillä tehdään |
|---|---|
| invoice_id | Tallenna se tilaustasi vastaan. Se tunnistaa maksun kaikkialla muualla. |
| payment_url | Ohjaa ostaja tänne. Ei muuta rakennettavaa. |
| address | Vain jos renderöit oman kassasi. Näytä se täsmälleen sellaisenaan — katso varoitus alla. |
| amount | Summa normaaliyksiköissä, täsmälleen kuten lähetit sen. Näytä tämä. |
| amount_minor | Sama summa kokonaislukuna pienimmässä yksikössä. Laske tällä. |
| expires_at | Näytä lähtölaskenta. Sen umpeuduttua osoitetta ei enää valvota tälle laskulle. |
| status | Täällä aina pending. Todelliset muutokset saapuvat webhookilla. |
UQ… päälähiverkossa, 0Q… testiverkossa). Muunna se, kaunista se, tai vaihda se saman osoitteen toiseen koodaukseen, ja vielä käyttöönottamattomaan lompakkoon lähetetyt kolikot kimpoavat takaisin lähettäjälle.Laske haku#
GET /api/merchant/v1/invoices/{invoice_id}
/api/merchant/v1/invoices/{invoice_id}Sama muoto kuin yllä, ja status, paid sekä paid_minor heijastavat nykyhetkeä: paid on saapunut määrä normaaliyksiköissä, paid_minor sama kokonaislukuna pienimmässä yksikössä. Hyödyllinen varasuunnitelmana, kun webhook jäi saamatta, tai kiitossivulla.
Kysele sitä enintään muutaman sekunnin välein, ja pidä webhookeja ensisijaisena kanavana. Toiselle kaupalle kuuluvat laskut vastaavat 404, ei 403, joten tunnusta ei voi tutkia olemassaolon suhteen.
Peruuta lasku#
POST /api/merchant/v1/invoices/{invoice_id}/cancel
/api/merchant/v1/invoices/{invoice_id}/cancelSulkee laskun, joka on vielä avoinna — pending tai underpaid — ja vapauttaa sen osoitteen. Käytä, kun asiakas hylkää kassan: osoitteet ovat rajallinen resurssi, ja niiden palauttaminen pitää joukon terveenä.
Lasku, joka ei ole enää avoinna, vastaa 409. underpaid-laskun peruminen ei palauta kolikoita kenellekään: jo hyvitetty raha jää saldollesi, ja ainoa mikä sulkeutuu on täydennyksen vastaanottaminen.
Webhookit#
Mitä saapuu, ja miten se todennetaan.
Aseta webhook-osoite avainta luodessasi. Teemme siihen POST-kutsun, kun maksu hyvitetään — ja kun lisätarkistukseen pidätetty talletus hylätään. Jokainen toimitus on allekirjoitettu, ja yritämme uudelleen noin puolentoista vuorokauden ajan, kunnes vastaat 2xx. Luovuta tavara status: paid- tai overpaid-tilalla, älä pelkän kutsun saapumisen perusteella.
Tapahtumat
| Tapahtuma | Milloin | Mitä rungossa on |
|---|---|---|
| payment.credited | Siirto on vahvistettu ketjussa, palkkiomme on peritty ja loput ovat saldollasi. | Alla luetellut kentät. |
| payment.rejected | Lisätarkistukseen pidätetty talletus (ks. Tilaviiteopas) on hylätty. Rahat eivät päädy saldollesi. | invoice_id, order_id, asset, amount, tx_hash ja reason. Älä luovuta tavaraa; jos lasku oli jo paid aiemman siirron ansiosta, tämä tapahtuma koskee ylimääräistä talletusta eikä tuota maksua. |
Mitä saapuu
{
"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"
}
}Kenttien yhdistäminen
| Kenttä | Merkitys |
|---|---|
| event_id | Yksilöllinen jokaiselle tapahtumalle; myös X-Paysell-Event-Id-otsikossa. Tallenna se ja jätä toistot huomiotta — katso alla. |
| data.order_id | Oma viitteesi. Etsi tilauksesi tällä. |
| data.amount | Mitä ostaja lähetti tässä siirrossa, pienimmässä yksikössä — toisin kuin API, joka ottaa normaaliyksiköitä. |
| data.fee | Mitä otimme, pienimmässä yksikössä. |
| data.credited | Mitä päätyi saldollesi: amount − fee, pienimmässä yksikössä. |
| data.paid_minor | Tälle laskulle tähän mennessä saapunut kokonaissumma, pienimmässä yksikössä. Kenttä, jolla on merkitystä underpaid-tilassa: tila kertoo, että saapui vähemmän, tämä kertoo kuinka paljon vähemmän. |
| data.asset | Kolikko, joka todella saapui. Ei välttämättä se kolikko, jota lasku pyysi. |
| data.asset_mismatch | On mukana, arvona true, vain kun saapunut kolikko ei ole laskun kolikko. Rahat hyvitetään sinulle, mutta lasku jää maksamatta eikä status ole koskaan paid. |
| data.invoice_asset | Tulee asset_mismatch-kentän kanssa: kolikko, jota lasku oikeasti pyytää. |
| data.status | Laskun tila juuri nyt: pending, underpaid, paid, overpaid tai expired. Vertaa siihen, mitä odotit. |
| data.tx_hash | Ketjun transaktio, arkistointiisi ja tukea varten. |
Otsikot jokaisessa toimituksessa
X-Paysell-Event: payment.credited
X-Paysell-Event-Id: 99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp: 1789000000
X-Paysell-Signature: sha256=6f1c0e6a…| Otsikko | Merkitys |
|---|---|
| X-Paysell-Event | Tapahtuman tyyppi: payment.credited tai payment.rejected. |
| X-Paysell-Event-Id | Yksilöllinen jokaiselle tapahtumalle. Tämä on arvo, jonka perusteella kaksoiskappaleet karsitaan. |
| X-Paysell-Timestamp | Allekirjoitushetki unix-sekunteina. Se on osa allekirjoitettua merkkijonoa. |
| X-Paysell-Signature | sha256= ja sen perässä heksamuotoinen HMAC. Katso alla. |
Allekirjoituksen todentaminen
Jokainen pyyntö allekirjoitetaan webhook-salaisuudella, joka näytettiin kerran avaimen luonnin yhteydessä. Allekirjoitus on HMAC-SHA256(secret, "{timestamp}.{raw_body}") — aikaleima X-Paysell-Timestamp-otsikosta, kirjaimellinen piste, sitten rungon tavut. Tarkista se ennen toimintaa: ilman tätä kuka tahansa, joka saa selville osoitteesi, voi antaa sinulle maksetun tilauksen.
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)
}Allekirjoita raa'at rungon tavut, täsmälleen sellaisina kuin ne vastaanotettiin. Jäsennä JSON ja serialisoi se uudelleen, ja tavut muuttuvat — avainten järjestys, välilyönnit — eikä allekirjoitus täsmää. Vertaa vakioajassa (hmac.compare_digest, crypto.timingSafeEqual): tavallinen == palaa nopeammin, kun ensimmäinen tavu on väärä, ja tuo ero riittää allekirjoituksen arvaamiseen tavu kerrallaan.
Aikaleimaikkuna
Hylkää kaikki, joiden aikaleima on yli viisi minuuttia oman kellosi ajasta, kumpaan tahansa suuntaan. Aikaleima on allekirjoitetun merkkijonon sisällä juuri siksi, ettei sitä voi muokata rikkomatta allekirjoitusta; ikkuna on se, mikä tekee siitä suojan. Ilman sitä kerran talteen otettu pyyntö pysyy voimassa ikuisesti ja se voidaan toistaa milloin tahansa — pelkkä allekirjoitus ei vanhene koskaan. Pidä palvelimesi kello NTP:ssä, tai tämä tarkistus alkaa hylätä kelvollisia toimituksia.
Kaksoiskappaleet
Sama tapahtuma voi saapua useammin kuin kerran. Se ei ole bugi: yritämme uudelleen kunnes vastaat 2xx, ja toimitus, joka onnistui mutta jonka vastaus ei koskaan tavoittanut meitä, lähetetään uudelleen. Tallenna X-Paysell-Event-Id (se tulee myös rungossa kenttänä event_id) ja varmista, ettei toinen saapuminen tee mitään.
Uusintayritykset
Ensimmäinen yritys lähtee heti kun maksu on hyvitetty. Jos se epäonnistuu — aikakatkaisu, yhteys torjuttu, TLS-virhe, uudelleenohjaus tai mikä tahansa muu kuin 2xx-tila — yritämme uudelleen kiinteän aikataulun mukaan:
1 min → 5 min → 15 min → 1 h → 6 h → 24 hYhteensä seitsemän yritystä, jotka jakautuvat noin 31 tunnille. Ensimmäiset ovat lähellä toisiaan, koska tavallisin syy on juuri uudelleenkäynnistynyt vastaanottaja, joka on jo takaisin pystyssä; myöhemmät ovat harvassa, koska vuorokauden alhaalla olleen palvelimen jankuttaminen ei auta ketään.
Viimeisen yrityksen jälkeen toimitus merkitään tilaan dropped ja lopetamme itse. Se ei ole menetetty: maksurivi tilialueellasi näyttää tilan, yritysten määrän ja virheluokan, ja siinä on Lähetä uudelleen -painike, joka aloittaa tuoreen kierroksen kaikkia seitsemää yritystä. Toinen keinosi on GET /api/merchant/v1/invoices/{invoice_id} — lasku tietää aina oman tilansa.
Millainen webhook-osoitteen pitää olla
Osoite tarkistetaan, kun tallennat sen, ja uudelleen ennen jokaista yksittäistä toimitusta. Tarkistuksessa kaatuvaan osoitteeseen vastataan tallennushetkellä 422 ja code: "webhook_url_rejected", ja jos se alkaa kaatua vasta myöhemmin, toimitus merkitään tilaan failed — ilman uusintayrityksiä. Säännöt:
- Vain `https://`, ja portti 443. Webhook kuljettaa maksutietoja; pelkkänä http:nä ne ovat kenen tahansa reitin varrella olevan luettavissa.
- Verkkotunnus, ei IP-osoite. Tarvitset varmenteen joka tapauksessa, eikä varmenteita myönnetä paljaille IP-osoitteille.
- Ei `localhost`, eikä
.local-,.internal-,.corp-,.lan- tai.test-nimeä — palvelimemme eivät ylety sinun verkkoosi, ja nimi, joka selviää meidän verkkomme sisällä, on täsmälleen se, jota emme saa kutsua. - Ei tunnuksia osoitteessa (
https://user:pass@…). Laita oma tokenisi polkuun tai kyselyparametriin, jos tarvitset sellaisen. - Jokaisen osoitteen, johon nimi selviää, on oltava julkinen — sekä A että AAAA. Yksityiset, loopback-, link-local- ja CGNAT-alueet torjutaan, ja tarkistus toistetaan ennen jokaista toimitusta, joten tietueen kääntäminen myöhemmin osoitteeseen
127.0.0.1ei sekään toimi. - Uudelleenohjaus on virhe, ei välietappi. Emme seuraa niitä: antamasi osoite tarkistettiin,
Location-otsikossa olevaa ei.
Vastaa nopeasti
Mikä tahansa 2xx kelpaa, kymmenen sekunnin kuluessa — se on koko aikakatkaisumme, yhteyden muodostus mukaan lukien. Vastaa ensin, tee hidas työ jälkikäteen; päätepiste, joka odottaa omaa tietokantaansa ennen vastaamista, kirjataan ennen pitkää aikakatkaisuksi ja sille yritetään uudelleen, ja käsittelet saman tapahtuman kahdesti. Kaikki muu — 4xx, 5xx, uudelleenohjaus, jumittuminen — lasketaan epäonnistuneeksi yritykseksi ja palaa yllä olevaan aikatauluun.
Toimituksesta rehellisesti
Taattu on toimitusmekanismi: seitsemän yritystä noin 31 tunnin aikana, manuaalinen uudelleenlähetys tilialueeltasi, ja laskupäätepiste, joka tietää aina todellisen tilan. Rakenna kulku niin, ettei koskaan saapumaton webhook maksa sinulle mitään — lue lasku kiitossivullasi, tai täsmäytä avoimet laskut kerran tunnissa. Webhookit ovat nopea reitti, eivät ainoa reitti.
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.
Tilaviiteopas#
Jokainen lasku- ja maksutila selitettynä.
Lasku
| Tila | Merkitys | Mitä tehdä |
|---|---|---|
| pending | Odottaa maksua. | Pidä tilaus avoimena. |
| paid | Maksettu kokonaan. | Luovuta tavara. |
| overpaid | Saapui enemmän kuin pyydettiin. Ylijäämä hyvitetään sinulle kokonaan. | Luovuta tavara; palauta erotus halutessasi. |
| underpaid | Saapui vähemmän kuin pyydettiin. Lasku pysyy avoimena ja säilyttää osoitteensa: ostaja voi täydentää sen samaan paikkaan, ja paid_minor kertoo, kuinka paljon on jo sisällä. Se on maksettavissa loppueliniältään sekä 24 tunnin lisäajan verran expires_at-hetken jälkeen. | Odota täydennystä, tai sovi asiasta asiakkaan kanssa. Älä luovuta tavaraa — laskua ei ole maksettu. |
| expired | Ikkuna sulkeutui, lisäaika mukaan lukien. Voi silti sisältää rahaa: kaikki saapunut jäi saldollesi, ja paid_minor kertoo kuinka paljon. | Tarjoa uusi lasku. Älä hyväksy maksua vanhaan osoitteeseen: kun lasku vanhenee, osoite palautuu joukkoon, ja hyvin myöhäinen siirto on tukitapaus eikä automaattinen hyvitys. Tarkista paid_minor, ennen kuin kerrot asiakkaalle, ettei mitään saapunut. |
| cancelled | Sinun peruma. Osoite vapautuu takaisin joukkoon. | Ei mitään. |
Maksu
Näkyy tilialueellasi; hyödyllinen tukiessasi asiakasta kesken maksun.
| Tila | Merkitys |
|---|---|
| detected | Havaittu ketjussa, odottaa vahvistuksia. |
| confirmed | Verkko vahvisti sen. Hyvitys seuraavaksi. |
| credited | Saldollasi. Tällöin webhook laukeaa. |
| review | Pidätetty lisätarkistusta varten — esimerkiksi kolikot, jotka saapuvat osoitteeseen ilman avointa laskua. |
| rejected | Ei hyvitetty. Syy on kirjattu. |
Kun maksu menee tilaan `review`
Osa talletuksista pidätetään lisätarkistukseen sen sijaan, että ne hyvitettäisiin heti: poikkeuksellisen suuri summa, kolikoita saapumassa osoitteeseen, jolla ei ole avointa laskua, tai kaksi seuraamaamme lohkoketjulähdettä ovat eri mieltä tapahtuneesta. Mitään ei menetetä — rahat odottavat päätöstä, ja webhook laukeaa heti kun päätös on tehty, mikä voi kestää minuutteja tai tunteja. Pidä puuttuvaa kutsua maksulla, joka näkyy tilassa review, normaalina eikä virheenä. Jos asialla on merkitystä tilaukselle, kysy tuelta ja mainitse tx_hash.
Summat#
Ulos normaaliyksiköt, takaisin pienimmät.
Lähetä summat kolikon normaaliyksiköissä, merkkijonona — "1.5" on puolitoista. Ei JSON-luku eikä pienin yksikkö.
| Omaisuuserä | Desimaalit | Sinä lähetät | amount_minor vastauksessa |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "1.5" | "1500000" |
Merkkijono eikä luku, koska JSON-luvut ovat IEEE-754-doubleja eikä suuri summa nanotoneina mahdu sellaiseen enää tarkasti. Enemmän desimaaleja kuin kolikolla on tuottaa 422:n, ei koskaan hiljaista pyöristystä rahoistasi. Webhookeissa on toisin päin: siellä amount, fee ja credited ovat kokonaislukuja pienimmässä yksikössä, koska sitä puolta lukee koodi eikä ihminen.
// 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. 1500000nRajat#
Minimit, maksimit, ja nopeusrajoitukset.
| Raja | Arvo | Rikkomuksessa |
|---|---|---|
| Minimilasku | 0.1 TON · 3 USDT | 422 |
| Maksimilasku | 7000 TON · 10000 USDT | 422 |
| Laskuja tunnissa, kauppaa kohden | 60 | 429 |
| Avoimia laskuja kerralla | 20, kasvaa jokaisella maksetulla laskulla, enintään 200 | 429 |
| Laskun elinikä | 1 minuutti – 24 tuntia (oletus 2 tuntia) | 422 |
| API-pyyntöjä avainta kohden | 120 minuutissa | 429 + Retry-After |
Minimi ei ole byrokratiaa. Palkkiomme on prosenttiosuus, mutta maksun vastaanottaminen maksaa kiinteän summan: USDT:n siirtäminen pois vastaanotto-osoitteesta tarkoittaa sen rahoittamista ensin kaasulla — omasta pussistamme. Muutaman dollarin alapuolella palkkio ei kata käsittelyä, ja tällaisen maksun hyväksyminen tarkoittaisi sinulle sellaisen rahan hyvittämistä, jota on epätaloudellista siirtää.
Yläraja ei ole suuria kauppiaita vastaan — se on ansa yksikkövirheelle. Lähetä "5000000" siinä, missä tarkoitit "5", ja ilman rajaa syntyisi viiden miljoonan dollarin lasku: ostaja näkee järjettömän summan ja lähtee. Todellinen tilaus ei osu tähän kattoon koskaan, virhe aina. Molemmat katot ovat asetuksia (invoice_max_ton, invoice_max_usdt) ja niitä voidaan nostaa kaupallesi — kysy vain.
Tuntiraja ja avoimien laskujen raja suojaavat molemmat osoitejoukkoa. Jokainen avoin lasku varaa vastaanotto-osoitteen, ja karkaava silmukka yhdellä sivustolla muuten tyhjentäisi joukon kaikilta. Uusi kauppa saa pitää 20 laskua avoinna kerralla; sallittu määrä kasvaa yhdellä jokaista todella perittyä laskua kohden, kattona 200. underpaid lasketaan avoimeksi — se pitää yhä osoitettaan varattuna ja odottaa loppuosaa. Hylätyn laskun peruminen palauttaa sen osoitteen välittömästi. Uudelleenyritykset samalla idempotency_key-arvolla eivät lasketa tuntirajaa vastaan.
Pyyntöraja on 120 minuutissa API-avainta kohden — kaksi kutsua sekunnissa, selvästi yli minkä tahansa todellisen tilausvirran. 429 kantaa mukanaan Retry-After-otsikon sekunteina: odota se aika sen sijaan, että yrittäisit uudelleen tiukassa silmukassa, mikä vain työntää ikkunaa kauemmas.
Virheet#
Tilakoodit, joita todella näet.
Virheet palautuvat JSON-muodossa, kahdessa eri muodossa. Kaikki, mistä me tai käsittelyydin päätämme, asettaa detail-kentän alle parin {code, message}. Pyyntörunko, joka ei läpäise validointia, asettaa sinne sen sijaan listan kenttävirheitä. Tarkista, kumman sait, ennen kuin luet detail.code — ja haaraudu `code`-arvon perusteella, älä koskaan `message`-kentän: sanamuoto voi muuttua milloin tahansa, koodi ei muutu.
{
"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
}
]
}| Tila | Milloin | Mitä tehdä |
|---|---|---|
| 401 | Avain puuttuu, on väärä, tai peruutettu. | Tarkista otsikko. Luo uusi avain, jos se peruutettiin. |
| 404 | Laskua ei ole, tai se kuuluu toiselle kaupalle. | Tarkista tunnus. Molempiin tapauksiin vastataan samalla tavalla tarkoituksella, jottei tunnuksia voi haravoida kokeilemalla. |
| 409 | Lasku on tilassa, joka kieltää tämän. | Lue ensin sen nykyinen tila. |
| 422 | Pyyntö on virheellinen tai summa on laskun rajojen ulkopuolella. | Viesti nimeää sekä lähetetyn arvon että rajan. |
| 429 | Liian monta laskua tänä tuntina, liian monta avoinna kerralla, tai liian monta pyyntöä. | Odota Retry-After loppuun ja yritä sitten uudelleen. |
| 502 | Emme saaneet yhteyttä käsittelyytimeen. | Yritä uudelleen samalla idempotenssiavaimella. |
Koodit
Meidän päättämämme muoto on {"detail": {"code": …, "message": …}}. Nämä ovat koodit, joita kauppias-API palauttaa.
| Koodi | Tila | Merkitys |
|---|---|---|
| invalid_api_key | 401 | Avain puuttuu, on virheellinen, tuntematon tai peruutettu. Kaikkiin neljään vastataan samalla tavalla, joten avainta ei voi haravoida kokeilemalla. |
| not_found | 404 | Kohdetta ei ole, tai se kuuluu toiselle kaupalle. |
| invalid_input | 422 | Pyyntö ei läpäissyt validointia ytimessä — virheellinen summa, liikaa desimaaleja, tai summa laskun rajojen ulkopuolella. |
| conflict | 409 | Toiminto on ristiriidassa nykyisen tilan kanssa, kuten sellaisen laskun peruminen, joka ei ole enää avoinna. |
| too_many_requests | 429 | Nopeusrajoitus: laskuja tunnissa, avoimia laskuja, tai pyyntöjä minuutissa. Retry-After kertoo, kuinka kauan odottaa. |
| cbc_unreachable | 502 | Emme saaneet yhteyttä käsittelyytimeen. Yritä uudelleen samalla idempotency_key-arvolla. |
| webhook_url_rejected | 422 | Vain avainta tallennettaessa: webhook-osoite ei läpäissyt yllä kuvattuja tarkistuksia. detail.reason nimeää, mikä sääntö — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials, ja niin edelleen. |
502 ei tarkoita, ettei laskua luotu — pyyntö on saattanut mennä läpi vastauksen kadotessa paluumatkalla. Yritä uudelleen samalla idempotency_key-arvolla, ja saat joko olemassa olevan laskun tai uuden, ei koskaan kahta.
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.
Hyvitykset#
Näin hyvität maksun asiakkaalle.
Hyvitykset hoituvat tuen kautta, eivät API-kutsulla. Hyvitys on uusi siirto osoitteeseen, jonka ihminen on antanut, ja maksunvälittäjä, joka lähettää rahaa takaisin automaattisesti API-kutsulla, on maksunvälittäjä, joka voidaan saada lähettämään rahaa hyökkääjän osoitteeseen. Siksi se on tarkoituksella manuaalista.
Hyvittääksesi ostajalle avaa tukipyyntö tilialueeltasi ja liitä mukaan invoice_id tai tx_hash, summa ja osoite, johon rahat lähetetään. Operaattori tarkistaa maksun, siirtää rahat pois saldoltasi ja vastaa samaan tukipyyntöön. Varaudu siihen, että tähän menee työpäivä, ei minuutti.
Kaksi seurausta, jotka kannattaa ottaa suunnittelussa huomioon. Liikamaksu hyvitetään sinulle kokonaan — emme pidä siitä mitään — joten erotuksen palauttaminen liikaa lähettäneelle ostajalle on sinun päätöksesi ja kulkee samaa reittiä. Ja alimaksettu lasku ei ole hyvitystapaus niin kauan kuin se on avoinna: rahat ovat saldollasi, osoitetta seurataan yhä, ja ostaja voi yksinkertaisesti täydentää sen. Vasta lisäajan jälkeen, kun lasku menee tilaan expired rahaa sisällään, on jotain päätettävää.
Testaus#
Näin testaat integraatiosi ennen julkaisua.
Avaimet ovat täällä tuotantoavaimia: jokainen myönnetty avain on sk_live_-avain tuotantoydintä ja TON-pääverkkoa vasten. Erillistä testiympäristöä ei ole, ja siinä on hyvä puolensa: käyt läpi täsmälleen sen polun, jota oikeat tilauksesi kulkevat.
Testaa siis niin kuin testaisit mitä tahansa oikeaan rahaan koskevaa: pienillä summilla. Luo lasku minimisummalla (0.1 TON tai 3 USDT), maksa se omasta lompakostasi ja seuraa koko polkua — maksusivu, webhook, allekirjoituksen tarkistus, tilauksesi kääntyminen maksetuksi. Palkkio peritään, ja kolikot todella liikkuvat.
Osat, joita voit harjoitella kuluttamatta mitään: laskun luonti ja luku, sen peruminen, 422 virheellisestä summasta, 401 väärästä avaimesta, ja oma allekirjoituksen todentamisesi — allekirjoita esimerkkirunko salaisuudellasi ja syötä se omalle käsittelijällesi. Ainoa, mikä todella vaatii oikean maksun, on viimeinen askel: aito payment.credited-webhook.
Suunnittele integraatio niin, ettei se riipu hiekkalaatikosta tai simuloidusta maksusta: tuotantopolku on nopeampi — ja todenmukaisempi — todentaa.
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.
Julkaisua edeltävä tarkistuslista#
Kymmenen asiaa tarkistettavaksi ennen käyttöönottoa.
- Avain on vain palvelinpuolella, ei koskaan selaimen JavaScriptissä.
- Webhookin allekirjoitus todennetaan merkkijonoa
"{timestamp}.{raw_body}"vasten, vakioajassa. - Yli viisi minuuttia vanhat toimitukset hylätään, ja palvelimen kello on NTP:ssä.
- Toistuva
X-Paysell-Event-Idei tee mitään toisella kerralla. - Webhook vastaa 2xx kymmenen sekunnin kuluessa; hidas työ tapahtuu jälkikäteen.
- Webhook-osoite on https://-verkkotunnus portissa 443, eikä sen edessä ole uudelleenohjausta.
- Menetetystä webhookista selviää: laskupäätepiste luetaan kiitossivulla tai täsmäytysajossa.
idempotency_keyluodaan kerran tilausta kohden ja käytetään uudelleen uusintayrityksissä.- Summat lähtevät merkkijonoina normaaliyksiköissä; webhookin luvut luetaan pienimpinä yksikköinä.
- Osoite näytetään täsmälleen sellaisena kuin se palautettiin, muuttamattomana.
overpaidjaunderpaidkäsitellään, ei vainpaid;expiredvoi silti kantaapaid_minor-arvoa.- Tavara luovutetaan
status: paid- taioverpaid-tilalla, ei koskaan pelkän kutsun saapumisen perusteella. 429käsitellään odottamallaRetry-Afterloppuun, ei yrittämällä heti uudelleen.- Saldot luetaan meiltä, ei seurata erikseen totuutena.
Jokin epäselvää?
Jos tämä sivu ei vastannut kysymykseesi, kyseessä on aukko dokumentaatiossa, ja siitä kannattaa kertoa meille. Kirjoita tililtäsi, niin korjaamme sivun, emme vain vastausta.