PaysellDocumentation

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.

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:

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

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

Network fees for moving your money afterwards are paid out of our own funds, not yours. Your balance is your balance.

Quick start

Three steps to a working checkout.

  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. It is shown once. Store it the way you store a database password, and never ship it to a browser.

  3. 3

    Create your first invoice

    One request, one link back.

curl
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:

http
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxA

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

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

Create an invoice

POST/api/merchant/invoices

Request body

FieldTypeRequiredDescription
assetstringyesEither TON or USDT_TON.
amountstringyesSmallest units, as a string. See Amounts.
order_idstringnoYour own reference. Comes back in the webhook — this is how you match a payment to an order.
descriptionstringnoShown to the buyer on the payment page.
ttl_minutesnumbernoHow long the invoice stays payable. 1–1440, default 30.
idempotency_keystringnoSend the same value when retrying and you get the same invoice back instead of a second one.

Response · 201

json
{
  "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

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.
amount_formattedHuman-readable amount, already scaled. Display this, compute with `amount`.
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/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

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

Closes 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

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",
    "tx_hash": "97a1f0…"
  }
}

Field mapping

FieldMeaning
event_idUnique per delivery. Store it and ignore repeats — see below.
data.order_idYour reference. Look your order up by this.
data.amountWhat the buyer sent, in smallest units.
data.feeWhat we took.
data.creditedWhat landed on your balance: amount − fee.
data.statuspaid, overpaid or underpaid — compare against what you expected.
data.tx_hashThe 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.

python
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

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. The invoice stays open for a top-up to the same address.Wait, or settle with the customer.
expiredThe window closed unpaid.Offer a new invoice. Do not accept payment to the old address.
cancelledCancelled by you.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 a human to look at — for example, coins arriving at an address with no open invoice.
rejectedNot credited. The reason is recorded.

Amounts

Amounts are strings of whole numbers in the coin's smallest unit — never decimals, never floats.

AssetDecimals1.5 of itHuman
TON9"1500000000"1.5 TON
USDT_TON6"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.

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

LimitValueOn breach
Minimum invoice0.1 TON · 3 USDT422
Maximum invoicenone
Invoices per hour, per shop60429
Invoice lifetime1 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.

json
{
  "detail": {
    "code": "invalid_input",
    "message": "invoice amount below the minimum: 0.010000 USDT_TON, minimum 3.000000 USDT_TON"
  }
}
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.
409The invoice is in a state that forbids this.Read its current status first.
422The request is malformed or the amount is below the minimum.The message names both the value sent and the limit.
429Too many invoices this hour.Back off and retry later.
502We 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_id does nothing the second time.
  • Webhook answers within ten seconds; slow work happens afterwards.
  • idempotency_key is generated once per order and reused on retries.
  • Amounts are integers in smallest units, as strings, everywhere.
  • Address is displayed exactly as returned, unmodified.
  • overpaid and underpaid are handled, not just paid.
  • 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.