Paysell

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.

Saldot säilytetään meillä ja ovat ainoa totuudenlähde. Näytä ne, mutta älä koskaan pidä toista kopiota auktoritatiivisena — kaksi laskuria ajautuvat ennen pitkää erilleen, eikä silloin kukaan tiedä, kumpi on oikein.

Miten maksu toimii#

Kuusi vaihetta, useimmat niistä meidän.

Kuusi vaihetta, useimmat niistä meidän:

  1. 1

    Asiakkaasi klikkaa maksa

    Palvelimesi kutsuu API:tamme summalla ja omalla tilausviitteelläsi.

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

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

Liikamaksu hyvitetään kokonaisuudessaan — emme pidä erotusta. Alimaksu jättää laskun avoimeksi, jotta ostaja voi täydentää sen samaan osoitteeseen.

Kolikoiden siirtäminen pois vastaanotto-osoitteesta maksaa verkon kaasua, ja sen maksamme me — se osa ei koske koskaan saldoasi. Nosto omaan osoitteeseesi on eri asia: siitä peritään oma palkkionsa, joka vähennetään pyytämästäsi summasta, ja tarkat luvut löytyvät hinnastosta.

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

    Lähetä ostaja osoitteeseen payment_url

    Se 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. 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 kun data.status on paid tai overpaid. 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

Todennus#

API-avaimesi, ja miten sitä käytetään.

Jokainen pyyntö kantaa avaimesi Authorization-otsikossa:

http
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA

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

Tämä avain luo laskuja sinun nimissäsi. Pidä se palvelinpuolella. Kaikki selaimen JavaScriptissä on julkista, riippumatta siitä kuinka hyvin piilotetulta se näyttää.

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.

Luo lasku#

POST /api/merchant/v1/invoices

POST/api/merchant/v1/invoices

Pyynnön runko

KenttäTyyppiPakollinenKuvaus
assetstringkylläJoko TON tai USDT_TON.
amountstringkylläKolikon normaaliyksiköt merkkijonona: "5" on 5 USDT. Ei enempää desimaaleja kuin kolikolla on. Katso Summat.
order_idstringeiOma viitteesi, enintään 200 merkkiä. Palautuu jokaisessa webhookissa — näin yhdistät maksun tilaukseen.
descriptionstringeiEnintään 1000 merkkiä. Näytetään ostajalle maksusivulla.
ttl_minutesnumbereiKuinka kauan lasku pysyy maksettavana, minuutteina. 1–1440; jätä pois, niin oletus pätee — tällä hetkellä 2 tuntia.
idempotency_keystringeiEnintää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

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

Kenttien yhdistäminen tilaukseesi

KenttäMitä sillä tehdään
invoice_idTallenna se tilaustasi vastaan. Se tunnistaa maksun kaikkialla muualla.
payment_urlOhjaa ostaja tänne. Ei muuta rakennettavaa.
addressVain jos renderöit oman kassasi. Näytä se täsmälleen sellaisenaan — katso varoitus alla.
amountSumma normaaliyksiköissä, täsmälleen kuten lähetit sen. Näytä tämä.
amount_minorSama summa kokonaislukuna pienimmässä yksikössä. Laske tällä.
expires_atNäytä lähtölaskenta. Sen umpeuduttua osoitetta ei enää valvota tälle laskulle.
statusTäällä aina pending. Todelliset muutokset saapuvat webhookilla.
Jos renderöit oman sivusi, tulosta osoite täsmälleen sellaisena kuin se palautettiin. Se on ei-kimmoisassa (non-bounceable) muodossa (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}

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

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

Sulkee 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

TapahtumaMilloinMitä rungossa on
payment.creditedSiirto on vahvistettu ketjussa, palkkiomme on peritty ja loput ovat saldollasi.Alla luetellut kentät.
payment.rejectedLisä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

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

Kenttien yhdistäminen

KenttäMerkitys
event_idYksilöllinen jokaiselle tapahtumalle; myös X-Paysell-Event-Id-otsikossa. Tallenna se ja jätä toistot huomiotta — katso alla.
data.order_idOma viitteesi. Etsi tilauksesi tällä.
data.amountMitä ostaja lähetti tässä siirrossa, pienimmässä yksikössä — toisin kuin API, joka ottaa normaaliyksiköitä.
data.feeMitä otimme, pienimmässä yksikössä.
data.creditedMitä päätyi saldollesi: amount − fee, pienimmässä yksikössä.
data.paid_minorTä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.assetKolikko, joka todella saapui. Ei välttämättä se kolikko, jota lasku pyysi.
data.asset_mismatchOn 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_assetTulee asset_mismatch-kentän kanssa: kolikko, jota lasku oikeasti pyytää.
data.statusLaskun tila juuri nyt: pending, underpaid, paid, overpaid tai expired. Vertaa siihen, mitä odotit.
data.tx_hashKetjun transaktio, arkistointiisi ja tukea varten.

Otsikot jokaisessa toimituksessa

http
X-Paysell-Event:      payment.credited
X-Paysell-Event-Id:   99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp:  1789000000
X-Paysell-Signature:  sha256=6f1c0e6a…
OtsikkoMerkitys
X-Paysell-EventTapahtuman tyyppi: payment.credited tai payment.rejected.
X-Paysell-Event-IdYksilöllinen jokaiselle tapahtumalle. Tämä on arvo, jonka perusteella kaksoiskappaleet karsitaan.
X-Paysell-TimestampAllekirjoitushetki unix-sekunteina. Se on osa allekirjoitettua merkkijonoa.
X-Paysell-Signaturesha256= 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:

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

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 h

Yhteensä 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.1 ei sekään toimi.
  • Uudelleenohjaus on virhe, ei välietappi. Emme seuraa niitä: antamasi osoite tarkistettiin, Location-otsikossa olevaa ei.
Tarkistus tehdään kahdesti tarkoituksella — kerran kun tallennat osoitteen, jotta kirjoitusvirheeseen vastataan heti eikä hiljaisella toimittamatta jättämisellä, ja kerran ennen jokaista lähetystä, koska verkkotunnuksen omistaja voi milloin tahansa kääntää sen sisäiseen osoitteeseen. Jos päätepisteesi siirtyy, päivitä avain ensin: torjuttu osoite ei toimita mitään eikä jää jonoon.

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.

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.

Tilaviiteopas#

Jokainen lasku- ja maksutila selitettynä.

Lasku

TilaMerkitysMitä tehdä
pendingOdottaa maksua.Pidä tilaus avoimena.
paidMaksettu kokonaan.Luovuta tavara.
overpaidSaapui enemmän kuin pyydettiin. Ylijäämä hyvitetään sinulle kokonaan.Luovuta tavara; palauta erotus halutessasi.
underpaidSaapui 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.
expiredIkkuna 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.
cancelledSinun peruma. Osoite vapautuu takaisin joukkoon.Ei mitään.

Maksu

Näkyy tilialueellasi; hyödyllinen tukiessasi asiakasta kesken maksun.

TilaMerkitys
detectedHavaittu ketjussa, odottaa vahvistuksia.
confirmedVerkko vahvisti sen. Hyvitys seuraavaksi.
creditedSaldollasi. Tällöin webhook laukeaa.
reviewPidätetty lisätarkistusta varten — esimerkiksi kolikot, jotka saapuvat osoitteeseen ilman avointa laskua.
rejectedEi 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äDesimaalitSinä lähetätamount_minor vastauksessa
TON9"1.5""1500000000"
USDT_TON6"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.

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

Rajat#

Minimit, maksimit, ja nopeusrajoitukset.

RajaArvoRikkomuksessa
Minimilasku0.1 TON · 3 USDT422
Maksimilasku7000 TON · 10000 USDT422
Laskuja tunnissa, kauppaa kohden60429
Avoimia laskuja kerralla20, kasvaa jokaisella maksetulla laskulla, enintään 200429
Laskun elinikä1 minuutti – 24 tuntia (oletus 2 tuntia)422
API-pyyntöjä avainta kohden120 minuutissa429 + 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.

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
    }
  ]
}
TilaMilloinMitä tehdä
401Avain puuttuu, on väärä, tai peruutettu.Tarkista otsikko. Luo uusi avain, jos se peruutettiin.
404Laskua ei ole, tai se kuuluu toiselle kaupalle.Tarkista tunnus. Molempiin tapauksiin vastataan samalla tavalla tarkoituksella, jottei tunnuksia voi haravoida kokeilemalla.
409Lasku on tilassa, joka kieltää tämän.Lue ensin sen nykyinen tila.
422Pyyntö on virheellinen tai summa on laskun rajojen ulkopuolella.Viesti nimeää sekä lähetetyn arvon että rajan.
429Liian monta laskua tänä tuntina, liian monta avoinna kerralla, tai liian monta pyyntöä.Odota Retry-After loppuun ja yritä sitten uudelleen.
502Emme 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.

KoodiTilaMerkitys
invalid_api_key401Avain puuttuu, on virheellinen, tuntematon tai peruutettu. Kaikkiin neljään vastataan samalla tavalla, joten avainta ei voi haravoida kokeilemalla.
not_found404Kohdetta ei ole, tai se kuuluu toiselle kaupalle.
invalid_input422Pyyntö ei läpäissyt validointia ytimessä — virheellinen summa, liikaa desimaaleja, tai summa laskun rajojen ulkopuolella.
conflict409Toiminto on ristiriidassa nykyisen tilan kanssa, kuten sellaisen laskun peruminen, joka ei ole enää avoinna.
too_many_requests429Nopeusrajoitus: laskuja tunnissa, avoimia laskuja, tai pyyntöjä minuutissa. Retry-After kertoo, kuinka kauan odottaa.
cbc_unreachable502Emme saaneet yhteyttä käsittelyytimeen. Yritä uudelleen samalla idempotency_key-arvolla.
webhook_url_rejected422Vain 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 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.

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.

Kohtele ensimmäistä oikeaa tilaustasi varsinaisena testinä: valitse pieni summa, pidä lasku auki hallintapaneelissa ja tarkista maksurivi sekä webhookin tila, ennen kuin ohjaat oikeita asiakkaita sen ääreen.

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.

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-Id ei 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_key luodaan 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.
  • overpaid ja underpaid käsitellään, ei vain paid; expired voi silti kantaa paid_minor-arvoa.
  • Tavara luovutetaan status: paid- tai overpaid-tilalla, ei koskaan pelkän kutsun saapumisen perusteella.
  • 429 käsitellään odottamalla Retry-After loppuun, 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.