Aceite pagamentos em cripto
A Paysell liquida TON e USDT na rede TON. Você cria uma fatura, nós entregamos um link, e você recebe um callback assinado assim que o dinheiro é confirmado na blockchain e creditado no seu saldo.
Visão geral#
O que a Paysell faz, e o que não faz.
A Paysell é um processador de pagamentos, não uma carteira. Você nunca lida com chaves privadas, monitora a blockchain ou decide quando uma transação é definitiva — isso é conosco.
Cada fatura recebe seu próprio endereço de recebimento. Quando um comprador a paga, esperamos a rede confirmar a transferência, descontamos nossa taxa e creditamos o restante no seu saldo. Você saca para qualquer endereço que quiser.
Como funciona um pagamento#
Seis passos, a maioria nossos.
Seis passos, a maioria nossos:
- 1
Seu cliente clica em pagar
Seu servidor chama nossa API com o valor e sua própria referência de pedido.
- 2
Entregamos um endereço
Um endereço de recebimento novo é retirado de um pool pré-gerado e vinculado a esta fatura. Um endereço pertence exatamente a uma fatura aberta, e é assim que um pagamento é associado a ela.
- 3
O cliente envia as moedas
Ele escaneia o QR code ou copia o endereço. Envie-o para a
payment_urlque retornamos e a página já é resolvida por nós — valor, endereço, QR, contagem regressiva, status ao vivo. - 4
Detectamos a transferência
Duas fontes independentes de dados da blockchain são consultadas, e suas respostas são comparadas. Se divergirem, paramos em vez de escolher a resposta mais conveniente.
- 5
Esperamos a finalidade
Inclusão na masterchain mais três blocos acima. Cerca de quinze segundos — um pagamento que parece confirmado e depois some seria sua perda, então não corremos esse risco.
- 6
Creditado, e você é avisado
A taxa é descontada, o restante cai no seu saldo, e um webhook assinado vai para o seu servidor com seu
order_id.
Do pagamento ao callback: cerca de um minuto — uns quinze segundos de confirmações de rede, o resto é nossa varredura de endereços monitorados.
Para onde vai o dinheiro#
A taxa, e sobre o que ela é calculada.
A taxa é de 0,2%, fixa para sua loja no momento em que é registrada. Se a taxa padrão mudar depois, a sua não muda — ela é gravada em cada fatura como um número, não como referência a uma configuração.
A taxa é cobrada sobre o que realmente chega, não sobre o que a fatura pedia. Fature 5 USDT e receba 20, a taxa é calculada sobre 20. Pagou a menos? É calculada sobre o que chegou.
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 USDTO pagamento a mais é creditado integralmente — não ficamos com a diferença. O pagamento a menos deixa a fatura aberta para o comprador completar no mesmo endereço.
Início rápido#
Cinco minutos até sua primeira fatura.
Cinco passos. Dois são cliques na sua área do cliente, um é uma única requisição do seu servidor, e os dois últimos acontecem sozinhos.
- 1
Crie uma loja
Na sua área do cliente. Ela começa a aceitar pagamentos imediatamente — sem esperar revisão. A verificação acontece silenciosamente em segundo plano e só restringe saques, não pagamentos recebidos.
- 2
Emita uma chave de API
Sua loja → Chaves de API → Nova chave. A chave e o segredo do webhook são mostrados uma única vez e nunca mais. Guarde-os como guardaria a senha de um banco de dados, e nunca os envie para o navegador.
- 3
Crie uma fatura
Uma requisição do seu servidor, um link de volta. Os quatro trechos abaixo enviam exatamente a mesma coisa.
- 4
Mande o comprador para a
payment_urlIsso é o checkout inteiro — valor, endereço, QR code, contagem regressiva, status ao vivo — e não há nada para você construir. Veja Checkout para o que o comprador realmente vê.
- 5
Espere o webhook
Assim que o dinheiro é confirmado on-chain e creditado, fazemos um POST de um evento
payment.creditedassinado para o seu servidor. Verifique a assinatura e então marque o pedido como pago — mas só quandodata.statusforpaidouoverpaid. Veja 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"
}'Redirecione o comprador para a payment_url da resposta. Pronto — o resto chega como 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.
Autenticação#
Sua chave de API, e como ela é usada.
Toda requisição carrega sua chave no cabeçalho Authorization:
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxAToda chave emitida aqui começa com sk_live_. O prefixo sk_test_ só existe em uma implantação apontada para a rede de testes, e nenhuma implantação dessas é oferecida — veja Testes. Guardamos um hash irreversível, não a chave em si, então ninguém, nem nós, pode mostrá-la de novo. Perdeu? Emita uma nova e revogue a antiga.
A loja é derivada da chave, por isso nenhuma requisição leva um id de loja. Uma chave só pode agir sobre sua própria loja.
O caminho leva versão: /api/merchant/v1/…. Dentro de uma versão só acrescentamos campos — nada é renomeado nem muda de sentido em silêncio. Uma mudança que quebraria o seu código ganha um novo prefixo, /v2, e /v1 continua funcionando por um prazo anunciado.
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.
Criar uma fatura#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoicesCorpo da requisição
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| asset | string | sim | TON ou USDT_TON. |
| amount | string | sim | Unidades normais da moeda, como string: "5" são 5 USDT. Não mais casas decimais do que a moeda tem. Ver Valores. |
| order_id | string | não | Sua própria referência, até 200 caracteres. Volta em todo webhook — é assim que você liga um pagamento a um pedido. |
| description | string | não | Até 1000 caracteres. Mostrado ao comprador na página de pagamento. |
| ttl_minutes | number | não | Por quanto tempo a fatura continua pagável, em minutos. 1–1440; omita e vale o padrão — hoje 2 horas. |
| idempotency_key | string | não | Até 200 caracteres. Envie o mesmo valor ao repetir e você recebe a mesma fatura de volta em vez de uma segunda. É um campo do corpo, não o cabeçalho Idempotency-Key — esse cabeçalho não é lido aqui. |
Resposta · 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"
}Como usar isso no seu pedido
| Campo | O que fazer com ele |
|---|---|
| invoice_id | Guarde-o junto ao seu pedido. É o que identifica o pagamento em tudo mais. |
| payment_url | Redirecione o comprador para cá. Não há mais nada para construir. |
| address | Só se você montar seu próprio checkout. Mostre-o exatamente como recebido — veja o aviso abaixo. |
| amount | O valor em unidades normais, exatamente como você enviou. Exiba este. |
| amount_minor | O mesmo valor como inteiro na unidade mínima. Calcule com este. |
| expires_at | Mostre uma contagem regressiva. Depois que passar, o endereço para de ser monitorado para esta fatura. |
| status | Aqui é sempre pending. Mudanças reais chegam por webhook. |
UQ… na mainnet, 0Q… na testnet). Convertê-lo, embelezá-lo ou trocá-lo por outra codificação do mesmo endereço faz com que moedas enviadas a uma carteira ainda não implantada voltem ao remetente.Consultar uma fatura#
GET /api/merchant/v1/invoices/{invoice_id}
/api/merchant/v1/invoices/{invoice_id}Mesmo formato acima, com status, paid e paid_minor refletindo o presente: paid é quanto chegou em unidades normais, e paid_minor o mesmo como inteiro na unidade mínima. Útil como reserva quando um webhook foi perdido, ou numa página de agradecimento.
Consulte no máximo a cada poucos segundos, e trate webhooks como o canal principal. Faturas de outra loja respondem 404 — não 403, então um id não pode ser sondado quanto à existência.
Cancelar uma fatura#
POST /api/merchant/v1/invoices/{invoice_id}/cancel
/api/merchant/v1/invoices/{invoice_id}/cancelFecha uma fatura que ainda está aberta — pending ou underpaid — e libera seu endereço. Use quando o cliente abandona o checkout: endereços são um recurso finito, e devolvê-los mantém o pool saudável.
Uma fatura que não está mais aberta responde 409. Cancelar uma underpaid não devolve moedas a ninguém: o dinheiro já creditado continua no seu saldo, e o que se encerra é apenas a aceitação de um complemento.
Webhooks#
O que chega, e como verificar.
Defina uma URL de webhook ao criar a chave. Fazemos um POST para ela quando um pagamento é creditado — e quando um depósito retido para uma verificação adicional é recusado. Toda entrega é assinada, e continuamos tentando por cerca de um dia e meio até você responder 2xx. Libere a mercadoria com status: paid ou overpaid, não pela simples chegada da chamada.
Eventos
| Evento | Quando | O que o corpo traz |
|---|---|---|
| payment.credited | A transferência foi confirmada on-chain, nossa taxa foi retida e o restante está no seu saldo. | Os campos listados abaixo. |
| payment.rejected | Um depósito retido para uma verificação adicional (veja Referência de status) foi recusado. O dinheiro não vai chegar ao seu saldo. | invoice_id, order_id, asset, amount, tx_hash e reason. Não libere a mercadoria; se a fatura já estava paid por uma transferência anterior, este evento é sobre o depósito extra, não sobre aquele pagamento. |
O que chega
{
"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"
}
}Mapeamento de campos
| Campo | Significado |
|---|---|
| event_id | Único por evento; também vem no cabeçalho X-Paysell-Event-Id. Guarde-o e ignore repetições — veja abaixo. |
| data.order_id | Sua referência. Procure seu pedido por ela. |
| data.amount | O que o comprador enviou nesta transferência, na unidade mínima — ao contrário da API, que recebe unidades normais. |
| data.fee | O que retivemos, na unidade mínima. |
| data.credited | O que caiu no seu saldo: amount − fee, na unidade mínima. |
| data.paid_minor | Total recebido nesta fatura até agora, na unidade mínima. O campo que importa em underpaid: o status diz que chegou menos, este diz quanto a menos. |
| data.asset | A moeda que de fato chegou. Não necessariamente a moeda que a fatura pedia. |
| data.asset_mismatch | Presente, e igual a true, apenas quando a moeda que chegou não é a da fatura. O dinheiro é creditado a você, mas a fatura continua não paga e status nunca será paid. |
| data.invoice_asset | Vem junto com asset_mismatch: a moeda que a fatura realmente pede. |
| data.status | O status da fatura agora: pending, underpaid, paid, overpaid ou expired. Compare com o que você esperava. |
| data.tx_hash | A transação on-chain, para seus registros e suporte. |
Cabeçalhos em toda entrega
X-Paysell-Event: payment.credited
X-Paysell-Event-Id: 99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp: 1789000000
X-Paysell-Signature: sha256=6f1c0e6a…| Cabeçalho | Significado |
|---|---|
| X-Paysell-Event | O tipo do evento: payment.credited ou payment.rejected. |
| X-Paysell-Event-Id | Único por evento. É este o valor pelo qual deduplicar. |
| X-Paysell-Timestamp | Quando assinamos, em segundos unix. Faz parte da string assinada. |
| X-Paysell-Signature | sha256= seguido do HMAC em hexadecimal. Veja abaixo. |
Verificando a assinatura
Toda requisição é assinada com o segredo do webhook mostrado uma única vez quando você criou a chave. A assinatura é HMAC-SHA256(secret, "{timestamp}.{raw_body}") — o timestamp de X-Paysell-Timestamp, um ponto literal, e então os bytes do corpo. Verifique antes de agir: sem isso, qualquer um que descubra sua URL pode te entregar um pedido pago.
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)
}Assine os bytes brutos do corpo, exatamente como recebidos. Se você analisar o JSON e re-serializá-lo, os bytes mudam — ordem das chaves, espaçamento — e a assinatura não vai bater. Compare em tempo constante (hmac.compare_digest, crypto.timingSafeEqual): um == comum retorna mais rápido quando o primeiro byte está errado, e essa diferença basta para adivinhar uma assinatura byte a byte.
A janela do timestamp
Rejeite tudo cujo timestamp esteja a mais de cinco minutos do seu próprio relógio, em qualquer direção. O timestamp está dentro da string assinada justamente para não poder ser editado sem quebrar a assinatura; a janela é o que transforma isso em proteção. Sem ela, uma requisição capturada uma vez continua válida para sempre e pode ser reenviada a qualquer momento — a assinatura sozinha nunca expira. Mantenha o relógio do seu servidor no NTP, ou esta verificação vai começar a rejeitar entregas legítimas.
Duplicatas
O mesmo evento pode chegar mais de uma vez. Isso não é um bug: repetimos até você responder 2xx, e uma entrega que teve sucesso mas cuja resposta nunca chegou até nós é enviada de novo. Registre X-Paysell-Event-Id (ele também vem como event_id no corpo) e faça a segunda chegada não fazer nada.
Novas tentativas
A primeira tentativa sai assim que o pagamento é creditado. Se ela falhar — timeout, conexão recusada, erro de TLS, um redirecionamento, ou qualquer status fora do 2xx — tentamos de novo em um cronograma fixo:
1 min → 5 min → 15 min → 1 h → 6 h → 24 hSete tentativas no total, espalhadas por cerca de 31 horas. As primeiras ficam próximas porque a causa habitual é um receptor que estava reiniciando e já voltou; as últimas ficam esparsas porque martelar um servidor que está fora do ar há um dia não ajuda ninguém.
Depois da última tentativa a entrega é marcada como dropped e paramos por conta própria. Ela não se perde: a linha do pagamento na sua área do cliente mostra o estado, a contagem de tentativas e a classe do erro, com um botão Enviar novamente que inicia uma nova rodada das sete tentativas. Seu outro recurso é GET /api/merchant/v1/invoices/{invoice_id} — a fatura sempre sabe o próprio status.
Como deve ser uma URL de webhook
A URL é verificada quando você a salva, e de novo antes de cada entrega. Uma URL que não passa na verificação recebe 422 e code: "webhook_url_rejected" no momento de salvar, e marca a entrega como failed — sem novas tentativas — se começar a falhar depois. As regras:
- Apenas `https://`, e porta 443. Um webhook carrega dados de pagamento; em http puro eles são legíveis por qualquer um no caminho.
- Um nome de domínio, não um endereço IP. Você precisa de um certificado de qualquer forma, e certificados não são emitidos para IPs nus.
- Nada de `localhost`, nem nomes
.local,.internal,.corp,.lanou.test— nossos servidores não alcançam a sua rede, e um nome que resolve dentro da nossa é exatamente o que não podemos chamar. - Sem credenciais na URL (
https://user:pass@…). Coloque seu próprio token no caminho ou em um parâmetro de consulta, se precisar de um. - Todo endereço para o qual o nome resolve precisa ser público — A e AAAA, os dois. Faixas privadas, de loopback, link-local e CGNAT são recusadas, e a verificação se repete antes de cada entrega, então apontar o registro para
127.0.0.1depois também não funciona. - Um redirecionamento é uma falha, não um salto. Não os seguimos: o endereço que você nos deu foi verificado, o do cabeçalho
Locationnão foi.
Responda rápido
Qualquer 2xx serve, dentro de dez segundos — esse é todo o nosso timeout, conexão incluída. Responda primeiro, faça o trabalho lento depois; um endpoint que espera pelo próprio banco de dados antes de responder acabará registrado como timeout e será tentado de novo, e você vai processar o mesmo evento duas vezes. Qualquer outra coisa — um 4xx, um 5xx, um redirecionamento, um travamento — conta como tentativa falha e volta para o cronograma acima.
Entrega, com honestidade
O que é garantido é o mecanismo de entrega: sete tentativas ao longo de cerca de 31 horas, um reenvio manual pela sua área do cliente, e um endpoint de fatura que sempre sabe o status real. Monte o fluxo de modo que um webhook que nunca chega não te custe nada — leia a fatura na sua página de agradecimento, ou concilie as faturas abertas a cada hora. Webhooks são o caminho rápido, não o único.
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.
Referência de status#
Todos os status de fatura e pagamento, explicados.
Fatura
| Status | Significado | O que fazer |
|---|---|---|
| pending | Aguardando pagamento. | Mantenha o pedido aberto. |
| paid | Paga integralmente. | Libere o produto. |
| overpaid | Chegou mais do que o pedido. O excedente é creditado a você integralmente. | Libere o produto; reembolse a diferença se quiser. |
| underpaid | Chegou menos do que o pedido. A fatura continua aberta e mantém seu endereço: o comprador pode complementar no mesmo lugar, e paid_minor diz quanto já entrou. Ela segue pagável pelo resto da sua vida útil mais um período de tolerância de 24 horas após expires_at. | Espere o complemento, ou acerte com o cliente. Não libere o produto — a fatura não está paga. |
| expired | A janela fechou, período de tolerância incluído. Ainda pode carregar dinheiro: o que chegou permaneceu no seu saldo, e paid_minor diz quanto. | Ofereça uma nova fatura. Não aceite pagamento no endereço antigo: assim que uma fatura expira, o endereço volta para o pool, e uma transferência muito atrasada vira caso de suporte em vez de crédito automático. Confira paid_minor antes de dizer ao cliente que nada foi recebido. |
| cancelled | Cancelada por você. O endereço volta para o pool. | Nada. |
Pagamento
Visível na sua área do cliente; útil ao dar suporte a um cliente durante o pagamento.
| Status | Significado |
|---|---|
| detected | Visto on-chain, aguardando confirmações. |
| confirmed | A rede confirmou. Creditando em seguida. |
| credited | No seu saldo. É quando o webhook dispara. |
| review | Retido para uma verificação adicional — por exemplo, moedas chegando a um endereço sem fatura aberta. |
| rejected | Não creditado. O motivo é registrado. |
Quando um pagamento vai para `review`
Alguns depósitos ficam retidos para uma verificação adicional em vez de serem creditados na hora: um valor incomumente alto, moedas chegando a um endereço sem fatura aberta, ou as duas fontes de blockchain que consultamos discordando sobre o que aconteceu. Nada se perde — o dinheiro espera por uma decisão e o webhook dispara assim que ela sai, o que pode levar minutos ou horas. Trate a ausência de callback em um pagamento exibido como review como algo normal, e não como falha. Se isso importa para um pedido, fale com o suporte e informe o tx_hash.
Valores#
Unidades normais ao enviar, unidades mínimas ao receber.
Envie os valores nas unidades normais da moeda, como string — "1.5" é um e meio. Não um número JSON, nem a unidade mínima.
| Ativo | Casas decimais | Você envia | amount_minor na resposta |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "1.5" | "1500000" |
String e não número, porque números JSON são doubles IEEE-754 e um valor grande em nanotons deixa de caber neles com exatidão. Mais casas decimais do que a moeda tem é 422, nunca um arredondamento silencioso do seu dinheiro. Nos webhooks é o contrário: ali amount, fee e credited são inteiros na unidade mínima, porque aquele lado é lido por código, não por uma pessoa.
// 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. 1500000nLimites#
Mínimos, máximos e limites de taxa.
| Limite | Valor | Ao violar |
|---|---|---|
| Fatura mínima | 0.1 TON · 3 USDT | 422 |
| Fatura máxima | 7000 TON · 10000 USDT | 422 |
| Faturas por hora, por loja | 60 | 429 |
| Faturas abertas ao mesmo tempo | 20, crescendo a cada fatura paga, até 200 | 429 |
| Vida útil da fatura | 1 minuto – 24 horas (padrão 2 horas) | 422 |
| Requisições de API por chave | 120 por minuto | 429 + Retry-After |
O mínimo não é burocracia. Nossa taxa é percentual, mas receber um pagamento custa um valor fixo: tirar USDT de um endereço de recebimento significa abastecê-lo com gás primeiro, do nosso próprio bolso. Abaixo de alguns dólares a taxa não cobre o manuseio, e aceitar tal pagamento significaria creditar a você dinheiro que não compensa movimentar.
O máximo não existe por causa de grandes lojas — é uma armadilha para o erro de unidades. Envie "5000000" onde queria "5" e, sem ele, você teria uma fatura de cinco milhões de dólares: o comprador vê um valor absurdo e vai embora. Um pedido real nunca encosta nesse teto; um erro sempre encosta. Os dois tetos são configurações (invoice_max_ton, invoice_max_usdt) e podem ser elevados para a sua loja — é só pedir.
O limite por hora e o limite de faturas abertas protegem o pool de endereços. Cada fatura aberta ocupa um endereço de recebimento, e um loop descontrolado em um site esgotaria o pool para todo mundo. Uma loja nova pode manter 20 faturas abertas ao mesmo tempo; a cota cresce em uma unidade a cada fatura que ela efetivamente recebeu, até o teto de 200. underpaid conta como aberta — ela ainda está ocupando seu endereço, esperando o restante. Cancelar uma fatura abandonada devolve o endereço imediatamente. Repetições com a mesma idempotency_key não contam para o limite por hora.
O limite de requisições é 120 por minuto por chave de API — duas chamadas por segundo, bem acima de qualquer fluxo real de pedidos. Um 429 traz um cabeçalho Retry-After em segundos: espere esse tempo em vez de repetir em um laço apertado, o que só empurra a janela para mais longe.
Erros#
Os códigos de status que você realmente vai ver.
Os erros voltam como JSON, em dois formatos. Tudo o que nós ou o núcleo de processamento decidimos coloca um par {code, message} dentro de detail. Um corpo de requisição que não passa na validação coloca ali, em vez disso, uma lista de erros de campo. Verifique qual dos dois você recebeu antes de ler detail.code — e ramifique pelo `code`, nunca pela `message`: o texto pode mudar a qualquer momento, o código não.
{
"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 | Quando | O que fazer |
|---|---|---|
| 401 | Chave ausente, incorreta ou revogada. | Verifique o cabeçalho. Reemita a chave se ela foi revogada. |
| 404 | Não existe tal fatura, ou ela pertence a outra loja. | Verifique o id. Os dois casos respondem igual de propósito, para que um id não possa ser sondado. |
| 409 | A fatura está em um estado que proíbe isso. | Leia o status atual primeiro. |
| 422 | A requisição está malformada, ou o valor está fora dos limites da fatura. | A mensagem indica tanto o valor enviado quanto o limite. |
| 429 | Faturas demais nesta hora, abertas demais ao mesmo tempo, ou requisições demais. | Aguarde o Retry-After e tente de novo. |
| 502 | Não conseguimos alcançar o núcleo de processamento. | Tente novamente com a mesma chave de idempotência. |
Códigos
O formato decidido por nós é {"detail": {"code": …, "message": …}}. Estes são os códigos que a API do lojista retorna.
| Código | Status | Significado |
|---|---|---|
| invalid_api_key | 401 | A chave está ausente, malformada, é desconhecida ou foi revogada. Os quatro casos respondem igual, então uma chave não pode ser sondada. |
| not_found | 404 | Não existe tal objeto, ou ele pertence a outra loja. |
| invalid_input | 422 | A requisição não passou na validação do núcleo — um valor inválido, casas decimais demais, um valor fora dos limites da fatura. |
| conflict | 409 | A ação contradiz o estado atual, como cancelar uma fatura que não está mais aberta. |
| too_many_requests | 429 | Um limite de taxa: faturas por hora, faturas abertas, ou requisições por minuto. Retry-After diz quanto tempo esperar. |
| cbc_unreachable | 502 | Não conseguimos alcançar o núcleo de processamento. Tente novamente com a mesma idempotency_key. |
| webhook_url_rejected | 422 | Só ao salvar uma chave: a URL de webhook não passou nas verificações acima. detail.reason indica qual regra — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials, e assim por diante. |
Um 502 não significa que a fatura não foi criada — a requisição pode ter sido concluída com a resposta perdida no caminho de volta. Tente novamente com a mesma idempotency_key e você recebe a fatura existente ou uma nova, nunca duas.
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.
Reembolsos#
Como reembolsar um cliente.
O reembolso é feito pelo suporte, não por uma chamada de API. Um reembolso é uma nova transferência para um endereço que uma pessoa forneceu, e um processador de pagamentos que devolve dinheiro automaticamente numa chamada de API é um processador que pode ser levado a mandar dinheiro para o endereço de um atacante. Por isso é deliberadamente manual.
Para reembolsar um comprador, abra um chamado de suporte pela sua área do cliente com o invoice_id ou o tx_hash, o valor, e o endereço de destino. Um operador confere o pagamento, tira o dinheiro do seu saldo e responde no mesmo chamado. Conte com um dia útil, não com um minuto.
Duas consequências que valem ser consideradas no seu desenho. O pagamento a mais é creditado a você integralmente — não ficamos com nada dele — então devolver a diferença a um comprador que enviou demais é decisão sua e segue o mesmo caminho. E uma fatura com pagamento a menos não é caso de reembolso enquanto continua aberta: o dinheiro está no seu saldo, o endereço segue monitorado, e o comprador pode simplesmente completar. Só depois do período de tolerância, quando a fatura vai para expired com dinheiro nela, é que existe uma decisão a tomar.
Testes#
Como testar a sua integração antes do lançamento.
As chaves aqui são de produção: toda chave emitida é uma chave sk_live_ contra o núcleo de produção e a mainnet TON. Não existe um ambiente de teste separado, e isso tem um lado bom: você percorre exatamente o caminho que os seus pedidos reais vão percorrer.
Então teste como você testaria qualquer coisa que envolva dinheiro real: com valores pequenos. Crie uma fatura no mínimo (0.1 TON ou 3 USDT), pague-a da sua própria carteira e acompanhe o caminho inteiro — a página de pagamento, o webhook, a verificação da assinatura, seu pedido virando pago. A taxa se aplica, e as moedas realmente se movem.
As partes que você pode exercitar sem gastar nada: criar e ler uma fatura, cancelar uma, o 422 em um valor malformado, o 401 em uma chave errada, e a sua própria verificação de assinatura — assine um corpo de exemplo com o seu segredo e passe-o para o seu próprio handler. O que realmente exige um pagamento real é só o último passo: um webhook payment.credited de verdade.
Planeje a integração de modo que ela não dependa de uma sandbox ou de um pagamento simulado: o caminho real é mais rápido — e mais fiel — de verificar.
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.
Checklist antes de ir ao ar#
Dez itens para conferir antes do lançamento.
- A chave fica só no servidor, nunca no JavaScript do navegador.
- A assinatura do webhook é verificada contra
"{timestamp}.{raw_body}", em tempo constante. - Entregas com mais de cinco minutos são rejeitadas, e o relógio do servidor está no NTP.
- Um
X-Paysell-Event-Idrepetido não faz nada na segunda vez. - O webhook responde 2xx em até dez segundos; o trabalho lento acontece depois.
- A URL de webhook é um domínio https:// na porta 443, sem nenhum redirecionamento na frente.
- Um webhook perdido é contornável: o endpoint da fatura é lido na página de agradecimento ou em uma varredura de conciliação.
idempotency_keyé gerada uma vez por pedido e reutilizada nas repetições.- Os valores saem em unidades normais como strings; os números do webhook são lidos como unidades mínimas.
- O endereço é exibido exatamente como retornado, sem modificação.
overpaideunderpaidsão tratados, não apenaspaid;expiredainda pode carregarpaid_minor.- A mercadoria é liberada com
status: paidouoverpaid, nunca pela simples chegada do callback. 429é tratado esperando oRetry-After, não repetindo na hora.- Os saldos são lidos de nós, não controlados à parte como verdade.
Algo não ficou claro?
Se esta página não respondeu à sua pergunta, isso é uma lacuna na documentação e vale a pena nos contar. Escreva pela sua área do cliente e vamos corrigir a página, não só a resposta.