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.
How a payment works#
Six steps, most of them ours.
Six steps, most of them ours:
- 1
Your customer clicks pay
Your server calls our API with the amount and your own order reference.
- 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
The customer sends coins
They scan the QR code or copy the address. Send them to the
payment_urlwe return and the page is handled for you — amount, address, QR, countdown, live status. - 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
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
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.
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 USDTOverpayment 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.
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
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
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
Create an invoice
One request from your server, one link back. The four snippets below all send exactly the same thing.
- 4
Send the buyer to
payment_urlThat 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
Wait for the webhook
Once the money is confirmed on chain and credited, we POST a signed
payment.creditedevent to your server. Verify the signature, then mark the order paid — but only whendata.statusispaidoroverpaid. 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
- 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.
Authentication#
Your API key, and how it's used.
Every request carries your key in the Authorization header:
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxAEvery 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.
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.
Create an invoice#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoicesRequest body
| Field | Type | Required | Description |
|---|---|---|---|
| asset | string | yes | Either TON or USDT_TON. |
| amount | string | yes | Normal units of the coin, as a string: "5" is 5 USDT. No more decimal places than the coin has. See Amounts. |
| order_id | string | no | Your own reference, up to 200 characters. Comes back in every webhook — this is how you match a payment to an order. |
| description | string | no | Up to 1000 characters. Shown to the buyer on the payment page. |
| ttl_minutes | number | no | How long the invoice stays payable, in minutes. 1–1440; omit it and the default applies — 2 hours today. |
| idempotency_key | string | no | Up 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
{
"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
| Field | What to do with it |
|---|---|
| invoice_id | Store it against your order. It is what identifies the payment everywhere else. |
| payment_url | Redirect the buyer here. Nothing else to build. |
| address | Only if you render your own checkout. Show it exactly as given — see the warning below. |
| amount | The amount in normal units, exactly as you sent it. Show this one. |
| amount_minor | The same amount as an integer in the smallest unit. Compute with this one. |
| expires_at | Show a countdown. After it passes the address stops being watched for this invoice. |
| status | Always pending here. Real changes arrive by webhook. |
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}
/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
/api/merchant/v1/invoices/{invoice_id}/cancelCloses 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
| Event | When | What the body carries |
|---|---|---|
| payment.credited | The transfer is confirmed on chain, our fee is taken, and the rest is on your balance. | The fields listed below. |
| payment.rejected | A 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
{
"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"
}
}Field mapping
| Field | Meaning |
|---|---|
| event_id | Unique per event; also in the X-Paysell-Event-Id header. Store it and ignore repeats — see below. |
| data.order_id | Your reference. Look your order up by this. |
| data.amount | What the buyer sent in this transfer, in the smallest unit — unlike the API, which takes normal units. |
| data.fee | What we took, in the smallest unit. |
| data.credited | What landed on your balance: amount − fee, in the smallest unit. |
| data.paid_minor | Total 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.asset | The coin that actually arrived. Not necessarily the coin the invoice asked for. |
| data.asset_mismatch | Present, 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_asset | Comes with asset_mismatch: the coin the invoice actually asks for. |
| data.status | The invoice's status now: pending, underpaid, paid, overpaid or expired. Compare against what you expected. |
| data.tx_hash | The on-chain transaction, for your records and support. |
Headers on every delivery
X-Paysell-Event: payment.credited
X-Paysell-Event-Id: 99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp: 1789000000
X-Paysell-Signature: sha256=6f1c0e6a…| Header | Meaning |
|---|---|
| X-Paysell-Event | The event type: payment.credited or payment.rejected. |
| X-Paysell-Event-Id | Unique per event. This is the value to deduplicate on. |
| X-Paysell-Timestamp | When we signed, in unix seconds. It is part of the signed string. |
| X-Paysell-Signature | sha256= 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:
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)
}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 hSeven 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,.lanor.testname — 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.1later 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
Locationheader was not.
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.
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.
Status reference#
Every invoice and payment status, explained.
Invoice
| Status | Meaning | What to do |
|---|---|---|
| pending | Waiting for payment. | Keep the order open. |
| paid | Paid in full. | Release the goods. |
| overpaid | More arrived than asked. The surplus is credited to you in full. | Release the goods; refund the difference if you wish. |
| underpaid | Less 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. |
| expired | The 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. |
| cancelled | Cancelled by you. The address is released back to the pool. | Nothing. |
Payment
Visible in your account area; useful when supporting a customer mid-payment.
| Status | Meaning |
|---|---|
| detected | Seen on chain, waiting for confirmations. |
| confirmed | The network confirmed it. Crediting next. |
| credited | On your balance. This is when the webhook fires. |
| review | Held for an additional check — for example, coins arriving at an address with no open invoice. |
| rejected | Not 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.
| Asset | Decimals | You send | amount_minor in the response |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "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.
// 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. 1500000nLimits#
Minimums, maximums, and rate limits.
| Limit | Value | On breach |
|---|---|---|
| Minimum invoice | 0.1 TON · 3 USDT | 422 |
| Maximum invoice | 7000 TON · 10000 USDT | 422 |
| Invoices per hour, per shop | 60 | 429 |
| Open invoices at once | 20, growing with each paid invoice, up to 200 | 429 |
| Invoice lifetime | 1 minute – 24 hours (default 2 hours) | 422 |
| API requests per key | 120 per minute | 429 + 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.
{
"detail": {
"code": "invalid_input",
"message": "invoice amount below the minimum: 0.010000 USDT_TON, minimum 3.000000 USDT_TON"
}
}{
"detail": [
{
"type": "string_type",
"loc": ["body", "amount"],
"msg": "Input should be a valid string",
"input": 5
}
]
}| Status | When | What to do |
|---|---|---|
| 401 | Key missing, wrong, or revoked. | Check the header. Reissue the key if it was revoked. |
| 404 | No such invoice, or it belongs to another shop. | Check the id. The two cases answer alike on purpose, so an id cannot be probed. |
| 409 | The invoice is in a state that forbids this. | Read its current status first. |
| 422 | The request is malformed, or the amount is outside the invoice limits. | The message names both the value sent and the limit. |
| 429 | Too many invoices this hour, too many open at once, or too many requests. | Wait out Retry-After, then retry. |
| 502 | We 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.
| Code | Status | Meaning |
|---|---|---|
| invalid_api_key | 401 | The key is missing, malformed, unknown or revoked. All four answer alike, so a key cannot be probed. |
| not_found | 404 | No such object, or it belongs to another shop. |
| invalid_input | 422 | The request did not pass validation in the core — a bad amount, too many decimal places, an amount outside the invoice limits. |
| conflict | 409 | The action contradicts the current state, such as cancelling an invoice that is no longer open. |
| too_many_requests | 429 | A rate limit: invoices per hour, open invoices, or requests per minute. Retry-After says how long to wait. |
| cbc_unreachable | 502 | We could not reach the processing core. Retry with the same idempotency_key. |
| webhook_url_rejected | 422 | Only 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 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.
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.
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.
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-Iddoes 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_keyis 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.
overpaidandunderpaidare handled, not justpaid;expiredmay still carrypaid_minor.- Goods are released on
status: paidoroverpaid, never on the callback merely arriving. 429is handled by waiting outRetry-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.