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
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:
- 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 is 1.9%, 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 1.9%: 0.380000 USDT (on 20, not on 5)
Credited: 19.620000 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
Three steps to a working checkout.
- 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. It is shown once. Store it the way you store a database password, and never ship it to a browser.
- 3
Create your first invoice
One request, one link back.
curl -X POST https://test.saleprofit.dev/api/merchant/invoices \
-H "Authorization: Bearer sk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"asset": "USDT_TON",
"amount": "5000000",
"order_id": "order-1042"
}'Redirect the buyer to the payment_url in the response. You are done — the rest arrives as a webhook.
Authentication
Every request carries your key in the Authorization header:
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxAKeys start with sk_live_ on mainnet and sk_test_ on the test network, so the two are never confused. We store a one-way hash, not the key itself — 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.
Create an invoice
/api/merchant/invoicesRequest body
| Field | Type | Required | Description |
|---|---|---|---|
| asset | string | yes | Either TON or USDT_TON. |
| amount | string | yes | Smallest units, as a string. See Amounts. |
| order_id | string | no | Your own reference. Comes back in the webhook — this is how you match a payment to an order. |
| description | string | no | Shown to the buyer on the payment page. |
| ttl_minutes | number | no | How long the invoice stays payable. 1–1440, default 30. |
| idempotency_key | string | no | Send the same value when retrying and you get the same invoice back instead of a second one. |
Response · 201
{
"invoice_id": "12c22c1a-a496-4c1e-abe3-72661ef8706e",
"payment_url": "https://test.saleprofit.dev/pay/12c22c1a-a496-4c1e-abe3-72661ef8706e",
"address": "0QBC5XZUsDy-bFk2lS1pIXfSrjCQyEjDOXNIa7JCCCTWqTlZ",
"asset": "USDT_TON",
"amount": "5000000",
"amount_formatted": "5.000000",
"status": "pending",
"order_id": "order-1042",
"description": "Pro subscription",
"expires_at": "2026-08-22T15:50:55Z",
"created_at": "2026-08-22T15: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_formatted | Human-readable amount, already scaled. Display this, compute with `amount`. |
| 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
/api/merchant/invoices/{invoice_id}Same shape as above, with status reflecting the present. 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
/api/merchant/invoices/{invoice_id}/cancelCloses an unpaid invoice early 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 already paid cannot be cancelled; that request answers 409.
Webhooks
Set a webhook URL when you create a key. Once a payment is credited we POST to it. This is the moment to release the goods.
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",
"tx_hash": "97a1f0…"
}
}Field mapping
| Field | Meaning |
|---|---|
| event_id | Unique per delivery. 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 smallest units. |
| data.fee | What we took. |
| data.credited | What landed on your balance: amount − fee. |
| data.status | paid, overpaid or underpaid — compare against what you expected. |
| data.tx_hash | The on-chain transaction, for your records and support. |
Verifying the signature
Every request is signed with the webhook secret shown when you created the key. Check it before acting — otherwise anyone who learns your URL can hand you a paid order.
import hmac, hashlib
def is_ours(raw_body: bytes, header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
# compare_digest, not ==: a plain comparison leaks the answer through timing
return hmac.compare_digest("sha256=" + expected, header)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.
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 event_id and make the second arrival do nothing.
Answer quickly
Any 2xx will do, within ten seconds. Do the slow work after replying. If you answer with an error we retry with growing pauses, so a short outage on your side loses nothing.
Status reference
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. The invoice stays open for a top-up to the same address. | Wait, or settle with the customer. |
| expired | The window closed unpaid. | Offer a new invoice. Do not accept payment to the old address. |
| cancelled | Cancelled by you. | 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 a human to look at — for example, coins arriving at an address with no open invoice. |
| rejected | Not credited. The reason is recorded. |
Amounts
Amounts are strings of whole numbers in the coin's smallest unit — never decimals, never floats.
| Asset | Decimals | 1.5 of it | Human |
|---|---|---|---|
| TON | 9 | "1500000000" | 1.5 TON |
| USDT_TON | 6 | "1500000" | 1.5 USDT |
Strings because JSON numbers are IEEE-754 doubles, and a yearly turnover in nanotons stops being exactly representable in one. Whole units because 0.1 + 0.2 is not 0.3 in binary floating point — a rounding error worth a hundredth of a cent per payment becomes real money at volume, and it never rounds in your favour twice in a row.
// Wrong — the amount is already inexact by the time it is sent
const amount = 1.5 * 1e6
// Right — integers throughout, string at the boundary
const amount = String(BigInt(15) * 100000n) // "1500000"Limits
| Limit | Value | On breach |
|---|---|---|
| Minimum invoice | 0.1 TON · 3 USDT | 422 |
| Maximum invoice | none | — |
| Invoices per hour, per shop | 60 | 429 |
| Invoice lifetime | 1 minute – 24 hours (default 30 min) | 422 |
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 hourly cap protects the address pool. Each open invoice holds an address, and a runaway loop on one site would otherwise drain the pool for everybody. Retries with the same idempotency_key do not count against it.
Errors
Errors come back as JSON with the HTTP status telling you what kind of problem it is.
{
"detail": {
"code": "invalid_input",
"message": "invoice amount below the minimum: 0.010000 USDT_TON, minimum 3.000000 USDT_TON"
}
}| 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. |
| 409 | The invoice is in a state that forbids this. | Read its current status first. |
| 422 | The request is malformed or the amount is below the minimum. | The message names both the value sent and the limit. |
| 429 | Too many invoices this hour. | Back off and retry later. |
| 502 | We could not reach the processing core. | Retry with the same idempotency key. |
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.
Go-live checklist
- Key is server-side only, never in browser JavaScript.
- Webhook signature is verified against the raw body.
- Repeated
event_iddoes nothing the second time. - Webhook answers within ten seconds; slow work happens afterwards.
idempotency_keyis generated once per order and reused on retries.- Amounts are integers in smallest units, as strings, everywhere.
- Address is displayed exactly as returned, unmodified.
overpaidandunderpaidare handled, not justpaid.- 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.