Acepta pagos en cripto
Paysell liquida TON y USDT en la red TON. Creas una factura, te damos un enlace, y recibes un callback firmado en cuanto el dinero se confirma en la cadena y se acredita en tu saldo.
Resumen#
Qué hace Paysell, y qué no.
Paysell es un procesador de pagos, no una billetera. Nunca manejas claves privadas, vigilas la blockchain ni decides cuándo una transacción es definitiva — de eso nos encargamos nosotros.
Cada factura obtiene su propia dirección de recepción. Cuando un comprador la paga, esperamos a que la red confirme la transferencia, descontamos nuestra comisión y acreditamos el resto en tu saldo. Retiras a la dirección que quieras.
Cómo funciona un pago#
Seis pasos, la mayoría nuestros.
Seis pasos, la mayoría nuestros:
- 1
Tu cliente hace clic en pagar
Tu servidor llama a nuestra API con el importe y tu propia referencia de pedido.
- 2
Entregamos una dirección
Se toma una dirección de recepción nueva de un fondo pregenerado y se vincula a esta factura. Una dirección pertenece exactamente a una factura abierta, que es cómo se relaciona un pago con ella.
- 3
El cliente envía las monedas
Escanea el código QR o copia la dirección. Envíalo a la
payment_urlque devolvemos y la página ya está resuelta por nosotros — importe, dirección, QR, cuenta regresiva, estado en vivo. - 4
Detectamos la transferencia
Se consultan dos fuentes independientes de datos de la blockchain y se comparan sus respuestas. Si difieren, nos detenemos en lugar de elegir la respuesta más conveniente.
- 5
Esperamos la finalidad
Inclusión en la masterchain más tres bloques encima. Unos quince segundos — un pago que parece confirmado y luego desaparece sería tu pérdida, así que no corremos ese riesgo.
- 6
Acreditado, y te avisamos
Se descuenta la comisión, el resto llega a tu saldo, y un webhook firmado va a tu servidor con tu
order_id.
Del pago al callback: cerca de un minuto — unos quince segundos de confirmaciones de red, el resto es nuestro barrido de direcciones vigiladas.
Adónde va el dinero#
La comisión, y sobre qué se calcula.
La comisión es del 0,2%, fija para tu tienda desde el momento en que se registra. Si la tarifa estándar cambia después, la tuya no — queda escrita en cada factura como un número, no como una referencia a una configuración.
La comisión se toma de lo que realmente llega, no de lo que pedía la factura. Facturas 5 USDT y recibes 20, la comisión se calcula sobre 20. Si pagan de menos, se calcula sobre lo que llegó.
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 USDTEl sobrepago se acredita en su totalidad — no nos quedamos con la diferencia. El pago insuficiente deja la factura abierta para que el comprador pueda completarla a la misma dirección.
Inicio rápido#
Cinco minutos hasta tu primera factura.
Cinco pasos. Dos son clics en tu área de cliente, uno es una sola solicitud desde tu servidor, y los dos últimos ocurren solos.
- 1
Crea una tienda
En tu área de cliente. Empieza a aceptar pagos de inmediato — sin esperar revisión. La verificación ocurre discretamente en segundo plano y solo restringe los retiros, no los pagos entrantes.
- 2
Emite una clave de API
Tu tienda → Claves de API → Nueva clave. La clave y el secreto del webhook se muestran una sola vez y nunca más. Guárdalos como guardarías una contraseña de base de datos, y nunca los envíes a un navegador.
- 3
Crea una factura
Una solicitud desde tu servidor, un enlace de vuelta. Los cuatro fragmentos de abajo envían exactamente lo mismo.
- 4
Envía al comprador a
payment_urlEse es todo el checkout — importe, dirección, código QR, cuenta atrás, estado en vivo — y no hay nada que construir. Mira Checkout para ver lo que el comprador ve realmente.
- 5
Espera el webhook
Cuando el dinero está confirmado en la cadena y acreditado, hacemos un POST de un evento
payment.creditedfirmado a tu servidor. Verifica la firma y luego marca el pedido como pagado — pero solo cuandodata.statusseapaiduoverpaid. Mira 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"
}'Redirige al comprador a la payment_url de la respuesta. Ya está — el resto llega 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.
Autenticación#
Tu clave de API, y cómo se usa.
Cada solicitud lleva tu clave en el encabezado Authorization:
Authorization: Bearer sk_live_IH4SNdnYsv-yabTuPCVln4vOvvGoEyxAToda clave emitida aquí empieza por sk_live_. El prefijo sk_test_ solo existe en un despliegue apuntado a la red de pruebas, y no se ofrece ningún despliegue así — mira Pruebas. Guardamos un hash irreversible, no la clave, así que nadie, ni siquiera nosotros, puede volver a mostrártela. ¿La perdiste? Emite una nueva y revoca la anterior.
La tienda se deriva de la clave, por eso ninguna solicitud lleva un id de tienda. Una clave solo puede actuar sobre su propia tienda.
La ruta lleva versión: /api/merchant/v1/…. Dentro de una versión solo añadimos campos: nada se renombra ni cambia de significado en silencio. Un cambio que rompería tu código recibe un prefijo nuevo, /v2, y /v1 sigue funcionando durante un plazo 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.
Crear una factura#
POST /api/merchant/v1/invoices
/api/merchant/v1/invoicesCuerpo de la solicitud
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| asset | string | sí | TON o USDT_TON. |
| amount | string | sí | Unidades normales de la moneda, como cadena: "5" son 5 USDT. No más decimales de los que tiene la moneda. Ver Importes. |
| order_id | string | no | Tu propia referencia, hasta 200 caracteres. Vuelve en cada webhook — así es como emparejas un pago con un pedido. |
| description | string | no | Hasta 1000 caracteres. Se le muestra al comprador en la página de pago. |
| ttl_minutes | number | no | Cuánto tiempo sigue siendo pagable la factura, en minutos. 1–1440; omítelo y se aplica el valor por defecto — hoy 2 horas. |
| idempotency_key | string | no | Hasta 200 caracteres. Envía el mismo valor al reintentar y recibirás la misma factura en lugar de una segunda. Es un campo del cuerpo, no el encabezado Idempotency-Key — aquí ese encabezado no se lee. |
Respuesta · 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"
}Cómo usarlo en tu pedido
| Campo | Qué hacer con él |
|---|---|
| invoice_id | Guárdalo junto a tu pedido. Es lo que identifica el pago en todo lo demás. |
| payment_url | Redirige al comprador aquí. No hay nada más que construir. |
| address | Solo si construyes tu propio checkout. Muéstrala exactamente como se recibió — ver la advertencia abajo. |
| amount | El importe en unidades normales, tal como lo enviaste. Muestra este. |
| amount_minor | El mismo importe como entero en la unidad mínima. Calcula con este. |
| expires_at | Muestra una cuenta regresiva. Después de que pase, la dirección deja de vigilarse para esta factura. |
| status | Aquí siempre es pending. Los cambios reales llegan por webhook. |
UQ… en mainnet, 0Q… en testnet). Si la conviertes, la embelleces o la cambias por otra codificación de la misma dirección, las monedas enviadas a una billetera aún no desplegada rebotarán al remitente.Leer una factura#
GET /api/merchant/v1/invoices/{invoice_id}
/api/merchant/v1/invoices/{invoice_id}Misma forma que arriba, con status, paid y paid_minor reflejando el presente: paid es cuánto ha llegado en unidades normales, y paid_minor lo mismo como entero en la unidad mínima. Útil como respaldo cuando se perdió un webhook, o en una página de agradecimiento.
Consúltala como mucho cada pocos segundos, y trata los webhooks como el canal principal. Las facturas que pertenecen a otra tienda responden 404 — no 403, así que no se puede sondear un id para ver si existe.
Cancelar una factura#
POST /api/merchant/v1/invoices/{invoice_id}/cancel
/api/merchant/v1/invoices/{invoice_id}/cancelCierra una factura que todavía está abierta — pending o underpaid — y libera su dirección. Úsalo cuando el cliente abandona el checkout: las direcciones son un recurso finito, y devolverlas mantiene el fondo saludable.
Una factura que ya no está abierta responde 409. Cancelar una underpaid no devuelve monedas a nadie: el dinero ya acreditado se queda en tu saldo, y lo único que se cierra es la aceptación de un complemento.
Webhooks#
Qué llega, y cómo verificarlo.
Configura una URL de webhook al crear la clave. Hacemos un POST a ella cuando se acredita un pago — y cuando se rechaza un depósito retenido para una comprobación adicional. Cada entrega va firmada, y seguimos reintentando durante cerca de día y medio hasta que respondas 2xx. Entrega la mercancía con status: paid u overpaid, no por el mero hecho de recibir la llamada.
Eventos
| Evento | Cuándo | Qué lleva el cuerpo |
|---|---|---|
| payment.credited | La transferencia está confirmada en la cadena, se ha descontado nuestra comisión y el resto está en tu saldo. | Los campos que se enumeran abajo. |
| payment.rejected | Un depósito retenido para una comprobación adicional (ver Referencia de estados) ha sido rechazado. El dinero no llegará a tu saldo. | invoice_id, order_id, asset, amount, tx_hash y reason. No entregues la mercancía; si la factura ya estaba paid por una transferencia anterior, este evento se refiere al depósito extra, no a ese pago. |
Qué llega
{
"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"
}
}Mapeo de campos
| Campo | Significado |
|---|---|
| event_id | Único por evento; también en el encabezado X-Paysell-Event-Id. Guárdalo e ignora las repeticiones — ver abajo. |
| data.order_id | Tu referencia. Busca tu pedido por esto. |
| data.amount | Lo que envió el comprador en esta transferencia, en la unidad mínima — a diferencia de la API, que acepta unidades normales. |
| data.fee | Lo que nos quedamos, en la unidad mínima. |
| data.credited | Lo que llegó a tu saldo: amount − fee, en la unidad mínima. |
| data.paid_minor | Total recibido en esta factura hasta ahora, en la unidad mínima. El campo que importa en underpaid: el estado dice que llegó menos, este dice cuánto menos. |
| data.asset | La moneda que llegó realmente. No tiene por qué ser la que pedía la factura. |
| data.asset_mismatch | Aparece, con valor true, solo cuando la moneda que llegó no es la de la factura. El dinero se te acredita, pero la factura sigue sin pagar y status nunca será paid. |
| data.invoice_asset | Llega junto con asset_mismatch: la moneda que la factura pide realmente. |
| data.status | El estado actual de la factura: pending, underpaid, paid, overpaid o expired. Compáralo con lo que esperabas. |
| data.tx_hash | La transacción en cadena, para tus registros y soporte. |
Encabezados en cada entrega
X-Paysell-Event: payment.credited
X-Paysell-Event-Id: 99f74f58-efbb-4af1-b0a3-76b0073f9e6b
X-Paysell-Timestamp: 1789000000
X-Paysell-Signature: sha256=6f1c0e6a…| Encabezado | Significado |
|---|---|
| X-Paysell-Event | El tipo de evento: payment.credited o payment.rejected. |
| X-Paysell-Event-Id | Único por evento. Este es el valor con el que deduplicar. |
| X-Paysell-Timestamp | Cuándo firmamos, en segundos unix. Forma parte de la cadena firmada. |
| X-Paysell-Signature | sha256= seguido del HMAC en hexadecimal. Ver abajo. |
Verificar la firma
Cada solicitud se firma con el secreto del webhook que se muestra una sola vez, al crear la clave. La firma es HMAC-SHA256(secret, "{timestamp}.{raw_body}") — la marca de tiempo de X-Paysell-Timestamp, un punto literal, y luego los bytes del cuerpo. Verifícala antes de actuar: sin esto, cualquiera que descubra tu URL puede entregarte un pedido pagado.
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)
}Firma los bytes crudos del cuerpo, exactamente como se recibieron. Si analizas el JSON y lo vuelves a serializar, los bytes cambian — orden de claves, espacios — y la firma no coincidirá. Compara en tiempo constante (hmac.compare_digest, crypto.timingSafeEqual): un == normal responde antes si el primer byte es incorrecto, y esa diferencia basta para adivinar una firma byte a byte.
La ventana de la marca de tiempo
Rechaza todo aquello cuya marca de tiempo se aleje más de cinco minutos de tu propio reloj, en cualquiera de los dos sentidos. La marca de tiempo va dentro de la cadena firmada precisamente para que no se pueda editar sin romper la firma; la ventana es lo que convierte eso en protección. Sin ella, una solicitud capturada una vez sigue siendo válida para siempre y puede reproducirse en cualquier momento — la firma por sí sola nunca caduca. Mantén el reloj de tu servidor sincronizado por NTP, o esta comprobación empezará a rechazar entregas buenas.
Duplicados
El mismo evento puede llegar más de una vez. No es un error: reintentamos hasta que respondas 2xx, y una entrega que tuvo éxito pero cuya respuesta nunca nos llegó se vuelve a enviar. Registra X-Paysell-Event-Id (también viene como event_id en el cuerpo) y haz que la segunda llegada no haga nada.
Reintentos
El primer intento sale en cuanto se acredita el pago. Si falla — tiempo de espera agotado, conexión rechazada, error de TLS, una redirección o cualquier estado que no sea 2xx — reintentamos con un calendario fijo:
1 min → 5 min → 15 min → 1 h → 6 h → 24 hSiete intentos en total, repartidos a lo largo de unas 31 horas. Los primeros van juntos porque la causa habitual es un receptor que estaba reiniciándose y ya ha vuelto; los últimos van espaciados porque martillear un servidor que lleva un día caído no ayuda a nadie.
Tras el último intento la entrega se marca como dropped y paramos por nuestra cuenta. No se pierde: la fila del pago en tu área de cliente muestra el estado, el número de intentos y la clase de error, con un botón Enviar de nuevo que inicia una tanda nueva de los siete intentos. Tu otro recurso es GET /api/merchant/v1/invoices/{invoice_id} — la factura siempre conoce su propio estado.
Cómo debe ser una URL de webhook
La URL se comprueba al guardarla, y otra vez antes de cada entrega. Una URL que no pasa la comprobación recibe un 422 con code: "webhook_url_rejected" al guardar, y marca la entrega como failed — sin reintentos — si empieza a fallar más tarde. Las reglas:
- Solo `https://`, y puerto 443. Un webhook lleva datos de pago; en http plano cualquiera en la ruta puede leerlos.
- Un nombre de dominio, no una dirección IP. De todos modos necesitas un certificado, y no se emiten certificados para IPs desnudas.
- Nada de `localhost`, ni nombres
.local,.internal,.corp,.lano.test— nuestros servidores no pueden alcanzar tu red, y un nombre que se resuelva dentro de la nuestra es exactamente lo que no debemos llamar. - Sin credenciales en la URL (
https://user:pass@…). Pon tu propio token en la ruta o en un parámetro de consulta si necesitas uno. - Todas las direcciones a las que resuelva el nombre deben ser públicas — tanto A como AAAA. Los rangos privados, de loopback, link-local y CGNAT se rechazan, y la comprobación se repite antes de cada entrega, así que apuntar luego el registro a
127.0.0.1tampoco funciona. - Una redirección es un fallo, no un salto. No las seguimos: la dirección que nos diste fue comprobada, y la de un encabezado
Locationno.
Responde rápido
Cualquier 2xx sirve, dentro de diez segundos — ese es todo nuestro tiempo de espera, conexión incluida. Responde primero y haz el trabajo lento después; un endpoint que espera a su propia base de datos antes de contestar acabará registrándose como tiempo agotado y se reintentará, y procesarás el mismo evento dos veces. Cualquier otra cosa — un 4xx, un 5xx, una redirección, un cuelgue — cuenta como intento fallido y vuelve al calendario de arriba.
La entrega, con franqueza
Lo garantizado es el mecanismo de entrega: siete intentos a lo largo de unas 31 horas, un reenvío manual desde tu área de cliente, y un endpoint de factura que siempre conoce el estado real. Diseña el flujo de modo que un webhook que nunca llega no te cueste nada — lee la factura en tu página de agradecimiento, o concilia las facturas abiertas una vez por hora. Los webhooks son la vía rápida, no la única vía.
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.
Referencia de estados#
Todos los estados de factura y pago, explicados.
Factura
| Estado | Significado | Qué hacer |
|---|---|---|
| pending | Esperando el pago. | Mantén el pedido abierto. |
| paid | Pagada por completo. | Entrega lo comprado. |
| overpaid | Llegó más de lo pedido. El excedente se te acredita por completo. | Entrega lo comprado; reembolsa la diferencia si lo deseas. |
| underpaid | Llegó menos de lo pedido. La factura sigue abierta y conserva su dirección: el comprador puede completar el pago en el mismo sitio, y paid_minor dice cuánto hay ya dentro. Sigue siendo pagable durante el resto de su vida más un periodo de gracia de 24 horas tras expires_at. | Espera a que se complete, o llega a un acuerdo con el cliente. No entregues la mercancía — la factura no está pagada. |
| expired | La ventana se cerró, periodo de gracia incluido. Puede seguir llevando dinero: lo que llegara se quedó en tu saldo, y paid_minor dice cuánto. | Ofrece una factura nueva. No aceptes pago en la dirección antigua: en cuanto una factura expira, la dirección vuelve al fondo común, y una transferencia muy tardía es un caso de soporte y no un abono automático. Revisa paid_minor antes de decirle al cliente que no se recibió nada. |
| cancelled | Cancelada por ti. La dirección vuelve al fondo común. | Nada. |
Pago
Visible en tu área de cliente; útil al dar soporte a un cliente durante el pago.
| Estado | Significado |
|---|---|
| detected | Visto en cadena, esperando confirmaciones. |
| confirmed | La red lo confirmó. A continuación, se acredita. |
| credited | En tu saldo. Es cuando se dispara el webhook. |
| review | Retenido para una comprobación adicional — por ejemplo, monedas que llegan a una dirección sin factura abierta. |
| rejected | No acreditado. Se registra el motivo. |
Cuando un pago pasa a `review`
Algunos depósitos se retienen para una comprobación adicional en lugar de acreditarse de inmediato: una suma inusualmente grande, monedas que llegan a una dirección sin factura abierta, o las dos fuentes de blockchain que consultamos discrepando sobre lo ocurrido. No se pierde nada — el dinero espera una resolución y el webhook se dispara en cuanto la hay, lo que puede ser minutos u horas después. Trata la ausencia de callback en un pago mostrado como review como algo normal, no como un fallo. Si es importante para un pedido, escribe a soporte citando el tx_hash.
Importes#
Unidades normales al enviar, unidades mínimas al recibir.
Envía los importes en las unidades normales de la moneda, como cadena — "1.5" es uno y medio. Ni un número JSON, ni la unidad mínima.
| Activo | Decimales | Tú envías | amount_minor en la respuesta |
|---|---|---|---|
| TON | 9 | "1.5" | "1500000000" |
| USDT_TON | 6 | "1.5" | "1500000" |
Cadena y no número, porque los números JSON son dobles IEEE-754 y una suma grande en nanotons deja de representarse con exactitud en uno. Más decimales de los que tiene la moneda es un 422, nunca un redondeo silencioso de tu dinero. En los webhooks es al revés: allí amount, fee y credited son enteros en la unidad mínima, porque ese lado lo lee código, no una persona.
// 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. 1500000nLímites#
Mínimos, máximos y límites de tasa.
| Límite | Valor | Al incumplirse |
|---|---|---|
| Factura mínima | 0.1 TON · 3 USDT | 422 |
| Factura máxima | 7000 TON · 10000 USDT | 422 |
| Facturas por hora, por tienda | 60 | 429 |
| Facturas abiertas a la vez | 20, creciendo con cada factura cobrada, hasta 200 | 429 |
| Vida de la factura | 1 minuto – 24 horas (2 horas por defecto) | 422 |
| Peticiones de API por clave | 120 por minuto | 429 + Retry-After |
El mínimo no es burocracia. Nuestra comisión es un porcentaje, pero cobrar un pago cuesta un importe fijo: sacar USDT de una dirección de recepción implica financiarla con gas primero, de nuestro bolsillo. Por debajo de unos pocos dólares la comisión no cubre el manejo, y aceptar tal pago significaría acreditarte dinero que no es rentable mover.
El máximo no existe por los comercios grandes: es una trampa para el error de unidades. Envía "5000000" donde querías "5" y, sin él, tendrías una factura de cinco millones de dólares: el comprador ve una cifra absurda y se marcha. Un pedido real nunca llega a este techo; un error siempre. Ambos techos son ajustes (invoice_max_ton, invoice_max_usdt) y pueden subirse para tu tienda — pídelo.
El límite por hora y el de facturas abiertas protegen los dos el fondo de direcciones. Cada factura abierta ocupa una dirección de recepción, y un bucle descontrolado en un sitio agotaría el fondo para todos los demás. Una tienda nueva puede tener 20 facturas abiertas a la vez; la cuota crece en una por cada factura que haya llegado a cobrar, hasta un techo de 200. underpaid cuenta como abierta — sigue ocupando su dirección, esperando el resto. Cancelar una factura abandonada devuelve su dirección de inmediato. Los reintentos con la misma idempotency_key no cuentan para el límite por hora.
El límite de peticiones es de 120 por minuto y por clave de API — dos llamadas por segundo, muy por encima de cualquier flujo real de pedidos. Un 429 trae un encabezado Retry-After en segundos: espera ese tiempo en lugar de reintentar en bucle cerrado, lo que solo aleja más la ventana.
Errores#
Los códigos de estado que realmente verás.
Los errores vuelven en JSON, con dos formas. Todo lo que decidimos nosotros o el núcleo de procesamiento pone un par {code, message} bajo detail. Un cuerpo de solicitud que no pasa la validación pone allí, en su lugar, una lista de errores de campo. Comprueba cuál de las dos recibiste antes de leer detail.code — y ramifica según `code`, nunca según `message`: la redacción puede cambiar en cualquier momento, el código no.
{
"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
}
]
}| Estado | Cuándo | Qué hacer |
|---|---|---|
| 401 | Clave ausente, incorrecta o revocada. | Revisa el encabezado. Reemite la clave si fue revocada. |
| 404 | No existe tal factura, o pertenece a otra tienda. | Revisa el id. Los dos casos responden igual a propósito, para que no se pueda sondear un id. |
| 409 | La factura está en un estado que lo prohíbe. | Lee primero su estado actual. |
| 422 | La petición está mal formada o el importe está fuera de los límites de la factura. | El mensaje indica tanto el valor enviado como el límite. |
| 429 | Demasiadas facturas esta hora, demasiadas abiertas a la vez o demasiadas peticiones. | Espera a que pase Retry-After y reintenta. |
| 502 | No pudimos alcanzar el núcleo de procesamiento. | Reintenta con la misma clave de idempotencia. |
Códigos
La forma que decidimos nosotros es {"detail": {"code": …, "message": …}}. Estos son los códigos que devuelve la API de comercio.
| Código | Estado | Significado |
|---|---|---|
| invalid_api_key | 401 | La clave falta, está mal formada, es desconocida o fue revocada. Los cuatro casos responden igual, así que no se puede sondear una clave. |
| not_found | 404 | No existe tal objeto, o pertenece a otra tienda. |
| invalid_input | 422 | La petición no pasó la validación en el núcleo — un importe incorrecto, demasiados decimales, un importe fuera de los límites de la factura. |
| conflict | 409 | La acción contradice el estado actual, como cancelar una factura que ya no está abierta. |
| too_many_requests | 429 | Un límite de tasa: facturas por hora, facturas abiertas o peticiones por minuto. Retry-After dice cuánto esperar. |
| cbc_unreachable | 502 | No pudimos alcanzar el núcleo de procesamiento. Reintenta con la misma idempotency_key. |
| webhook_url_rejected | 422 | Solo al guardar una clave: la URL del webhook no pasó las comprobaciones de arriba. detail.reason indica qué regla — scheme, port, ip_literal, local_hostname, private_address, dns_error, credentials, y demás. |
Un 502 no significa que la factura no se haya creado — la solicitud pudo haberse completado con la respuesta perdida en el camino de vuelta. Reintenta con la misma idempotency_key y obtendrás la factura existente o una nueva, nunca dos.
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#
Cómo reembolsar a un cliente.
Los reembolsos se gestionan a través del soporte, no con una llamada a la API. Un reembolso es una transferencia nueva a una dirección que ha facilitado una persona, y un procesador de pagos que devuelve dinero automáticamente al recibir una llamada a la API es un procesador de pagos al que se le puede hacer enviar dinero a la dirección de un atacante. Por eso es manual a propósito.
Para reembolsar a un comprador, abre un ticket de soporte desde tu área de cliente con el invoice_id o el tx_hash, el importe y la dirección de destino. Un operador revisa el pago, saca el dinero de tu saldo y responde en el mismo ticket. Cuenta con que esto lleve un día laborable, no un minuto.
Dos consecuencias que conviene tener en cuenta al diseñar. El sobrepago se te acredita por completo — no nos quedamos nada — así que devolver la diferencia a un comprador que envió de más es decisión tuya y sigue la misma vía. Y una factura pagada de menos no es un caso de reembolso mientras siga abierta: el dinero está en tu saldo, la dirección se sigue vigilando, y el comprador puede sencillamente completar el pago. Solo después del periodo de gracia, cuando la factura pasa a expired con dinero dentro, hay una decisión que tomar.
Pruebas#
Cómo probar tu integración antes de lanzar.
Las claves aquí son de producción: toda clave emitida es una clave sk_live_ contra el núcleo de producción y la mainnet de TON. No hay un entorno de pruebas aparte, y eso tiene una ventaja: recorres exactamente el camino que seguirán tus pedidos reales.
Así que prueba como probarías cualquier cosa que toque dinero real: con importes pequeños. Crea una factura por el mínimo (0.1 TON o 3 USDT), págala desde tu propia billetera y observa todo el camino — la página de pago, el webhook, la verificación de la firma, tu pedido pasando a pagado. La comisión se aplica, y las monedas se mueven de verdad.
Lo que puedes ejercitar sin gastar nada: crear y leer una factura, cancelarla, el 422 con un importe mal formado, el 401 con una clave incorrecta, y tu propia verificación de firma — firma un cuerpo de ejemplo con tu secreto y pásaselo a tu propio manejador. Lo único que exige de verdad un pago real es el último paso: un webhook payment.credited auténtico.
Planifica la integración para que no dependa de un sandbox ni de un pago simulado: la vía real se verifica más rápido — y con más fidelidad.
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.
Lista antes de salir a producción#
Diez puntos que revisar antes de lanzar.
- La clave está solo en el servidor, nunca en JavaScript de navegador.
- La firma del webhook se verifica contra
"{timestamp}.{raw_body}", en tiempo constante. - Las entregas de más de cinco minutos de antigüedad se rechazan, y el reloj del servidor está sincronizado por NTP.
- Un
X-Paysell-Event-Idrepetido no hace nada la segunda vez. - El webhook responde 2xx en diez segundos; el trabajo lento ocurre después.
- La URL del webhook es un dominio https:// en el puerto 443, sin ninguna redirección por delante.
- Un webhook perdido es sobrevivible: el endpoint de factura se lee en la página de agradecimiento o en un barrido de conciliación.
idempotency_keyse genera una vez por pedido y se reutiliza en reintentos.- Los importes salen como cadenas en unidades normales; las cifras del webhook se leen en unidades mínimas.
- La dirección se muestra exactamente como se devolvió, sin modificar.
overpaidyunderpaidse gestionan, no solopaid;expiredpuede llevar todavíapaid_minor.- La mercancía se entrega con
status: paiduoverpaid, nunca por el mero hecho de recibir la llamada. 429se gestiona esperando a que paseRetry-After, no reintentando de inmediato.- Los saldos se leen desde nosotros, no se rastrean por separado como verdad.
¿Algo no quedó claro?
Si esta página no respondió tu pregunta, eso es un vacío en la documentación y vale la pena contárnoslo. Escríbenos desde tu área de cliente y arreglaremos la página, no solo la respuesta.