Paysell

Accept crypto payments

Paysell settles TON and USDT on the TON network. You create an invoice, we hand you a link, and you get a signed callback once the money is confirmed on chain and credited to your balance.

Overview#

What Paysell does, and what it doesn't.

Paysell is a payment processor, not a wallet. You never handle private keys, watch the blockchain, or decide when a transaction is final — that is the part we take on.

Every invoice gets its own receiving address. When a buyer pays it, we wait for the network to confirm the transfer, deduct our fee, and credit the rest to your balance. You withdraw to any address you like.

Balances live with us and are the single source of truth. Display them, but never keep a second copy as authoritative — two counters always drift apart eventually, and then nobody knows which one is right.

How a payment works#

Six steps, most of them ours.

Six steps, most of them ours:

  1. 1

    Your customer clicks pay

    Your server calls our API with the amount and your own order reference.

  2. 2

    We hand out an address

    A fresh receiving address is taken from a pre-generated pool and tied to this invoice. One address belongs to exactly one open invoice, which is how a payment is matched back to it.

  3. 3

    The customer sends coins

    They scan the QR code or copy the address. Send them to the payment_url we return and the page is handled for you — amount, address, QR, countdown, live status.

  4. 4

    We spot the transfer

    Two independent sources of blockchain data are polled, and their answers are compared. If they disagree, we stop rather than pick the more convenient answer.

  5. 5

    We wait for finality

    Inclusion in the masterchain plus three blocks on top. Roughly fifteen seconds — a payment that looks settled but later disappears would be your loss, so we do not take that chance.

  6. 6

    Credited, and you are told

    The fee is deducted, the rest lands on your balance, and a signed webhook goes to your server carrying your order_id.

About a minute from payment to callback: roughly fifteen seconds of network confirmations, the rest is our sweep of watched addresses.

Where the money goes#

The fee, and what it's calculated on.

The fee is 0.2%, fixed for your shop at the moment it is registered. If the standard rate changes later, yours does not — it is written into each invoice as a number, not as a reference to a setting.

The fee is taken from what actually arrives, not from what the invoice asked for. Bill 5 USDT and receive 20, and the fee is calculated on 20. Underpay, and it is calculated on what came in.

example
Invoice:   5.000000 USDT
Received: 20.000000 USDT   (the buyer sent more)
Fee 0.2%:  0.040000 USDT   (on 20, not on 5)
Credited: 19.960000 USDT

Overpayment is credited in full — we are not keeping the difference. Underpayment leaves the invoice open so the buyer can top it up to the same address.

Getting coins off a receiving address costs network gas, and we pay it — that part never touches your balance. Withdrawing to your own address is a different thing: it carries its own fee, deducted from the amount you ask for, and the exact numbers live in the Fee Schedule.

Quick start#

Five minutes to your first invoice.

Five steps. Two are clicks in your account area, one is a single request from your server, and the last two happen on their own.

  1. 1

    Create a shop

    In your account area. It starts accepting payments immediately — no waiting for review. Verification happens quietly in the background and only gates withdrawals, not incoming payments.

  2. 2

    Issue an API key

    Your shop → API keys → New key. The key and the webhook secret are shown once and never again. Store them the way you store a database password, and never ship them to a browser.

  3. 3

    Create an invoice

    One request from your server, one link back. The four snippets below all send exactly the same thing.

  4. 4

    Send the buyer to payment_url

    That is the entire checkout — amount, address, QR code, countdown, live status — and there is nothing to build. See Checkout for what the buyer actually sees.

  5. 5

    Wait for the webhook

    Once the money is confirmed on chain and credited, we POST a signed payment.credited event to your server. Verify the signature, then mark the order paid — but only when data.status is paid or overpaid. See 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"
  }'

Redirect the buyer to the payment_url in the response. You are done — the rest arrives as a webhook.

What to do next

Authentication#

Your API key, and how it's used.

Every request carries your key in the Authorization header:

http
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA

Every key issued here starts with sk_live_. The sk_test_ prefix exists only on a test-network deployment, and no such deployment is offered — see Testing. We store a one-way hash, not the key itself, so nobody, us included, can show it to you again. Lost it? Issue a new one and revoke the old.

The shop is derived from the key, which is why no request ever takes a shop id. A key can only ever act on its own shop.

The path carries a version: /api/merchant/v1/…. Inside a version we only add fields — nothing is renamed and nothing quietly changes meaning. A change that would break your code gets a new prefix, /v2, and /v1 keeps working for an announced period.

This key creates invoices under your name. Keep it server-side. Anything in browser JavaScript is public, no matter how well hidden it looks.

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.

Create an invoice#

POST /api/merchant/v1/invoices

POST/api/merchant/v1/invoices

Request body

FieldTypeRequiredDescription
assetstringyesEither TON or USDT_TON.
amountstringyesNormal units of the coin, as a string: "5" is 5 USDT. No more decimal places than the coin has. See Amounts.
order_idstringnoYour own reference, up to 200 characters. Comes back in every webhook — this is how you match a payment to an order.
descriptionstringnoUp to 1000 characters. Shown to the buyer on the payment page.
ttl_minutesnumbernoHow long the invoice stays payable, in minutes. 1–1440; omit it and the default applies — 2 hours today.
idempotency_keystringnoUp to 200 characters. Send the same value when retrying and you get the same invoice back instead of a second one. A body field, not the Idempotency-Key header — that header is not read here.

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

Mapping it onto your order

FieldWhat to do with it
invoice_idStore it against your order. It is what identifies the payment everywhere else.
payment_urlRedirect the buyer here. Nothing else to build.
addressOnly if you render your own checkout. Show it exactly as given — see the warning below.
amountThe amount in normal units, exactly as you sent it. Show this one.
amount_minorThe same amount as an integer in the smallest unit. Compute with this one.
expires_atShow a countdown. After it passes the address stops being watched for this invoice.
statusAlways pending here. Real changes arrive by webhook.
If you render your own page, print the address exactly as returned. It is in non-bounceable form (UQ… on mainnet, 0Q… on testnet). Convert it, prettify it, or swap it for another encoding of the same address, and coins sent to a not-yet-deployed wallet bounce back to the sender.

Read an invoice#

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

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

Same shape as above, with status, paid and paid_minor reflecting the present: paid is how much has arrived in normal units, paid_minor the same as an integer in the smallest unit. Useful as a fallback when a webhook was missed, or on a thank-you page.

Poll it at most every few seconds, and treat webhooks as the primary channel. Invoices that belong to another shop answer 404 — not 403, so an id cannot be probed for existence.

Cancel an invoice#

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

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

Closes an invoice that is still open — pending or underpaid — and releases its address. Use it when the customer abandons checkout: addresses are a finite resource, and returning them keeps the pool healthy.

An invoice that is no longer open answers 409. Cancelling an underpaid one does not return coins to anybody: money already credited stays on your balance, and all that closes is the acceptance of a top-up.

Webhooks#

What arrives, and how to verify it.

Set a webhook URL when you create a key. We POST to it when a payment is credited — and when a deposit held for an additional check is declined. Every delivery is signed, and we keep retrying for about a day and a half until you answer 2xx. Release the goods on status: paid or overpaid, not on the call merely arriving.

Events

EventWhenWhat the body carries
payment.creditedThe transfer is confirmed on chain, our fee is taken, and the rest is on your balance.The fields listed below.
payment.rejectedA deposit held for an additional check (see Status reference) was declined. The money will not reach your balance.invoice_id, order_id, asset, amount, tx_hash and reason. Do not release the goods; if the invoice was already paid by an earlier transfer, this event is about the extra deposit, not about that payment.

What arrives

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

Field mapping

FieldMeaning
event_idUnique per event; also in the X-Paysell-Event-Id header. Store it and ignore repeats — see below.
data.order_idYour reference. Look your order up by this.
data.amountWhat the buyer sent in this transfer, in the smallest unit — unlike the API, which takes normal units.
data.feeWhat we took, in the smallest unit.
data.creditedWhat landed on your balance: amount − fee, in the smallest unit.
data.paid_minorTotal received on this invoice so far, in the smallest unit. The field that matters on underpaid: the status says less arrived, this says how much less.
data.assetThe coin that actually arrived. Not necessarily the coin the invoice asked for.
data.asset_mismatchPresent, and true, only when the coin that arrived is not the coin of the invoice. The money is credited to you, but the invoice stays unpaid and status is never paid.
data.invoice_assetComes with asset_mismatch: the coin the invoice actually asks for.
data.statusThe invoice's status now: pending, underpaid, paid, overpaid or expired. Compare against what you expected.
data.tx_hashThe on-chain transaction, for your records and support.

Headers on every delivery

http
X-Paysell-Event:      payment.credited
X-Paysell-Event-Id:   99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp:  1789000000
X-Paysell-Signature:  sha256=6f1c0e6a…
HeaderMeaning
X-Paysell-EventThe event type: payment.credited or payment.rejected.
X-Paysell-Event-IdUnique per event. This is the value to deduplicate on.
X-Paysell-TimestampWhen we signed, in unix seconds. It is part of the signed string.
X-Paysell-Signaturesha256= followed by the hex HMAC. See below.

Verifying the signature

Every request is signed with the webhook secret shown once when you created the key. The signature is HMAC-SHA256(secret, "{timestamp}.{raw_body}") — the timestamp from X-Paysell-Timestamp, a literal dot, then the body bytes. Check it before acting: without this, anyone who learns your URL can hand you a paid order.

Python:

python
import hmac, hashlib, time

def is_ours(body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:      # ±5 minutes
        return False
    signed = timestamp.encode() + b"." + body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    # compare_digest, not ==: a plain comparison leaks the answer through timing
    return hmac.compare_digest("sha256=" + expected, signature)

Node.js:

javascript
const crypto = require("node:crypto")

function isOurs(body, signature, timestamp, secret) {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false  // ±5 min
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(timestamp + ".").update(body)   // body is the raw Buffer, not a parsed object
    .digest("hex")
  const a = Buffer.from(expected), b = Buffer.from(signature)
  // timingSafeEqual throws when the lengths differ, so check that first
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}

Sign the raw body bytes, exactly as received. Parse the JSON and re-serialise it and the bytes change — key order, spacing — and the signature will not match. Compare in constant time (hmac.compare_digest, crypto.timingSafeEqual): a plain == returns faster on a wrong first byte, and that difference is enough to guess a signature one byte at a time.

The timestamp window

Reject anything whose timestamp is more than five minutes away from your own clock, in either direction. The timestamp is inside the signed string precisely so it cannot be edited without breaking the signature; the window is what turns that into protection. Without it, a request captured once stays valid forever and can be replayed at any time — the signature alone never expires. Keep your server's clock on NTP, or this check will start rejecting good deliveries.

Duplicates

The same event can arrive more than once. That is not a bug: we retry until you answer 2xx, and a delivery that succeeded but whose response never reached us gets sent again. Record X-Paysell-Event-Id (it also comes as event_id in the body) and make the second arrival do nothing.

Retries

The first attempt goes out as soon as the payment is credited. If it fails — timeout, connection refused, TLS error, a redirect, or any non-2xx status — we retry on a fixed schedule:

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

Seven attempts in total, spread over roughly 31 hours. The early ones are close together because the usual cause is a receiver that was restarting and is already back; the late ones are sparse because hammering a server that has been down for a day helps nobody.

After the last attempt the delivery is marked dropped and we stop on our own. It is not lost: the payment row in your account area shows the state, the attempt count and the error class, with a Send again button that starts a fresh run of all seven attempts. Your other recourse is GET /api/merchant/v1/invoices/{invoice_id} — the invoice always knows its own status.

What a webhook URL must look like

The URL is checked when you save it, and again before every single delivery. A URL that fails the check is answered with 422 and code: "webhook_url_rejected" at save time, and marks the delivery failed — with no retries — if it starts failing later. The rules:

  • `https://` only, and port 443. A webhook carries payment details; in plain http they are readable by anyone on the path.
  • A domain name, not an IP address. You need a certificate anyway, and certificates are not issued for bare IPs.
  • No `localhost`, and no .local, .internal, .corp, .lan or .test name — our servers cannot reach your network, and a name that resolves inside ours is exactly what we must not call.
  • No credentials in the URL (https://user:pass@…). Put your own token in the path or a query parameter if you need one.
  • Every address the name resolves to must be public — A and AAAA both. Private, loopback, link-local and CGNAT ranges are refused, and the check is repeated before each delivery, so pointing the record at 127.0.0.1 later does not work either.
  • Redirects are a failure, not a hop. We do not follow them: the address you gave us was checked, and the one in a Location header was not.
Verification happens twice on purpose — once when you save the URL, so a typo is answered immediately rather than by silent non-delivery, and once before each send, because the owner of a domain can repoint it at an internal address at any moment. If your endpoint moves, update the key first: a rejected URL delivers nothing and does not queue.

Answer quickly

Any 2xx will do, within ten seconds — that is our whole timeout, connection included. Answer first, do the slow work afterwards; an endpoint that waits for its own database before replying will eventually be recorded as a timeout and retried, and you will process the same event twice. Anything else — a 4xx, a 5xx, a redirect, a hang — counts as a failed attempt and goes back into the schedule above.

Delivery, honestly

What is guaranteed is the delivery mechanism: seven attempts over roughly 31 hours, a manual resend from your account area, and an invoice endpoint that always knows the real status. Build the flow so a webhook that never arrives costs you nothing — read the invoice on your thank-you page, or reconcile open invoices once an hour. Webhooks are the fast path, not the only path.

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.

Status reference#

Every invoice and payment status, explained.

Invoice

StatusMeaningWhat to do
pendingWaiting for payment.Keep the order open.
paidPaid in full.Release the goods.
overpaidMore arrived than asked. The surplus is credited to you in full.Release the goods; refund the difference if you wish.
underpaidLess arrived than asked. The invoice stays open and keeps its address: the buyer can top it up to the same place, and paid_minor says how much is in already. It stays payable for the rest of its lifetime plus a 24-hour grace period after expires_at.Wait for the top-up, or settle with the customer. Do not release the goods — the invoice is not paid.
expiredThe window closed, grace period included. May still carry money: whatever arrived stayed on your balance, and paid_minor says how much.Offer a new invoice. Do not accept payment to the old address: once an invoice expires the address goes back into the pool, and a very late transfer is a support case rather than an automatic credit. Check paid_minor before telling the customer nothing was received.
cancelledCancelled by you. The address is released back to the pool.Nothing.

Payment

Visible in your account area; useful when supporting a customer mid-payment.

StatusMeaning
detectedSeen on chain, waiting for confirmations.
confirmedThe network confirmed it. Crediting next.
creditedOn your balance. This is when the webhook fires.
reviewHeld for an additional check — for example, coins arriving at an address with no open invoice.
rejectedNot credited. The reason is recorded.

When a payment goes to `review`

Some deposits are held for an additional check instead of being credited straight away: an unusually large sum, coins arriving at an address with no open invoice, or the two blockchain sources we poll disagreeing about what happened. Nothing is lost — the money waits for a decision and the webhook fires as soon as there is one, which may be minutes or hours later. Treat a missing callback on a payment shown as review as normal rather than as a failure. If it matters for an order, ask support and quote the tx_hash.

Amounts#

Normal units going out, smallest units coming back.

Send amounts in the coin's normal units, as a string"1.5" is one and a half. Not a JSON number, and not the smallest unit.

AssetDecimalsYou sendamount_minor in the response
TON9"1.5""1500000000"
USDT_TON6"1.5""1500000"

A string rather than a number, because JSON numbers are IEEE-754 doubles and a large sum in nanotons stops being exactly representable in one. More decimal places than the coin has is a 422, never a silent rounding of your money. Webhooks go the other way: amount, fee and credited there are integers in the smallest unit, because that side is read by code, not by a person.

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

Limits#

Minimums, maximums, and rate limits.

LimitValueOn breach
Minimum invoice0.1 TON · 3 USDT422
Maximum invoice7000 TON · 10000 USDT422
Invoices per hour, per shop60429
Open invoices at once20, growing with each paid invoice, up to 200429
Invoice lifetime1 minute – 24 hours (default 2 hours)422
API requests per key120 per minute429 + Retry-After

The minimum is not bureaucracy. Our fee is a percentage, but collecting a payment costs a fixed amount: moving USDT off a receiving address means funding it with gas first, out of our pocket. Below a few dollars the fee does not cover the handling, and accepting such a payment would mean crediting you money that is uneconomical to move.

The maximum is not about big merchants — it is a trap for a mistake in units. Send "5000000" where you meant "5" and you would otherwise get an invoice for five million dollars: the buyer sees an absurd figure and leaves. A real order never reaches this ceiling; a mistake always does. Both ceilings are settings (invoice_max_ton, invoice_max_usdt) and can be raised for your shop — ask.

The hourly cap and the open-invoice cap both protect the address pool. Each open invoice holds a receiving address, and a runaway loop on one site would otherwise drain the pool for everybody. A new shop may hold 20 invoices open at once; the allowance grows by one for every invoice it has actually collected, to a ceiling of 200. underpaid counts as open — it is still holding its address, waiting for the rest. Cancelling an abandoned invoice returns its address immediately. Retries with the same idempotency_key do not count against the hourly cap.

The request limit is 120 per minute per API key — two calls a second, well above any real order flow. A 429 carries a Retry-After header in seconds: wait that long rather than retrying in a tight loop, which only pushes the window further out.

Errors#

The status codes you'll actually see.

Errors come back as JSON, in two shapes. Anything we or the processing core decide puts a {code, message} pair under detail. A request body that fails validation puts a list of field errors there instead. Check which one you got before reading detail.code — and branch on `code`, never on `message`: the wording can change at any time, the code will not.

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
    }
  ]
}
StatusWhenWhat to do
401Key missing, wrong, or revoked.Check the header. Reissue the key if it was revoked.
404No such invoice, or it belongs to another shop.Check the id. The two cases answer alike on purpose, so an id cannot be probed.
409The invoice is in a state that forbids this.Read its current status first.
422The request is malformed, or the amount is outside the invoice limits.The message names both the value sent and the limit.
429Too many invoices this hour, too many open at once, or too many requests.Wait out Retry-After, then retry.
502We could not reach the processing core.Retry with the same idempotency key.

Codes

The decided-by-us shape is {"detail": {"code": …, "message": …}}. These are the codes the merchant API returns.

CodeStatusMeaning
invalid_api_key401The key is missing, malformed, unknown or revoked. All four answer alike, so a key cannot be probed.
not_found404No such object, or it belongs to another shop.
invalid_input422The request did not pass validation in the core — a bad amount, too many decimal places, an amount outside the invoice limits.
conflict409The action contradicts the current state, such as cancelling an invoice that is no longer open.
too_many_requests429A rate limit: invoices per hour, open invoices, or requests per minute. Retry-After says how long to wait.
cbc_unreachable502We could not reach the processing core. Retry with the same idempotency_key.
webhook_url_rejected422Only when saving a key: the webhook URL failed the checks above. detail.reason names which rule — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials, and so on.

A 502 does not mean the invoice was not created — the request may have gone through with the answer lost on the way back. Retry with the same idempotency_key and you either get the existing invoice or a new one, never two.

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.

Refunds#

How to refund a buyer.

Refunds go through support, not through an API call. A refund is a new transfer to an address a person supplied, and a payment processor that sends money back automatically on an API call is a payment processor that can be made to send money to an attacker's address. So it is deliberately manual.

To refund a buyer, open a support ticket from your account area with the invoice_id or tx_hash, the amount, and the address to send to. An operator checks the payment, moves the money out of your balance, and answers in the same ticket. Expect this to take a working day, not a minute.

Two consequences worth designing around. Overpayment is credited to you in full — we keep none of it — so returning the difference to a buyer who sent too much is your call and follows the same route. And an underpaid invoice is not a refund case while it is still open: the money is on your balance, the address is still watched, and the buyer can simply top it up. Only after the grace period, when the invoice goes expired with money against it, is there a decision to make.

Testing#

How to test your integration before launch.

Keys here are live: every key issued is an sk_live_ key against the production core and the TON mainnet. There is no separate test environment, which has an upside: you exercise exactly the path your real orders will take.

So test the way you would test anything touching real money: on small amounts. Create an invoice for the minimum (0.1 TON or 3 USDT), pay it from your own wallet, and watch the whole path — the payment page, the webhook, the signature check, your order flipping to paid. The fee applies, and the coins really move.

The parts you can exercise without spending anything: creating and reading an invoice, cancelling one, the 422 on a malformed amount, the 401 on a wrong key, and your own signature verification — sign a sample body with your secret and feed it to your own handler. What genuinely requires a real payment is only the last step: an actual payment.credited webhook.

Plan the integration so it does not depend on a sandbox or a simulated payment: the live path is faster — and truer — to verify.

Treat your first live order as the real test: pick a small amount, keep the invoice open in the dashboard, and check the payment row and the webhook state before you point real customers at it.

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.

Go-live checklist#

Ten things to check before launch.

  • Key is server-side only, never in browser JavaScript.
  • Webhook signature is verified against "{timestamp}.{raw_body}", in constant time.
  • Deliveries older than five minutes are rejected, and the server's clock is on NTP.
  • Repeated X-Paysell-Event-Id does nothing the second time.
  • Webhook answers 2xx within ten seconds; slow work happens afterwards.
  • The webhook URL is an https:// domain on port 443, with no redirect in front of it.
  • A missed webhook is survivable: the invoice endpoint is read on the thank-you page or on a reconciliation sweep.
  • idempotency_key is generated once per order and reused on retries.
  • Amounts go out in normal units as strings; webhook figures are read as smallest units.
  • Address is displayed exactly as returned, unmodified.
  • overpaid and underpaid are handled, not just paid; expired may still carry paid_minor.
  • Goods are released on status: paid or overpaid, never on the callback merely arriving.
  • 429 is handled by waiting out Retry-After, not by retrying immediately.
  • Balances are read from us, not tracked separately as truth.

Something unclear?

If this page did not answer your question, that is a gap in the documentation and worth telling us about. Write from your account area and we will fix the page, not just the answer.