Enlaces de pago
Un enlace de pago es la unidad de cobro de Binkio: un importe, una moneda y una lista de formas en que tu cliente puede pagarlo. Es lo que hay detrás de un botón «Pagar» en tu tienda, de un cobro por correo o de un QR en el mostrador.
Lo que lo hace distinto de un botón de pago corriente: el pagador puede mandarte una cosa y tú recibir otra. Él transfiere pesos por SPEI y tu cuenta se abona en dólares digitales, o al revés. Eso es lo que aquí llamamos un pago cruzado, y no tienes que hacer nada para habilitarlo — sale de la lista de métodos que aceptes.
Crear un cobro
curl -X POST https://api.binkio.com/v1/payment_links \
-H "Authorization: Bearer bk_live_sec_…" \
-H "Idempotency-Key: pedido-4821" \
-H "Content-Type: application/json" \
-d '{
"amount": "1500.25",
"currency": "MXNB",
"description": "Pedido 4821",
"accepted_methods": ["SPEI", "MXNB", "USDC"],
"fee_bearer": "payer",
"expires_at": "2026-09-14T23:59:59Z",
"return_url": "https://tutienda.com/pedido/4821/gracias"
}'
| Campo | Obligatorio | Qué es |
|---|---|---|
amount | sí | Lo que recibe tu cuenta, como cadena decimal. Hasta 8 decimales |
currency | sí | MXNB, USDC o COP — la moneda en la que quedas abonado |
description | sí | Lo que ve el pagador. Máximo 255 caracteres |
accepted_methods | no | Cómo puede pagarte (lista). Sin esto, se habilitan los métodos viables. Ver Si pides métodos concretos |
fee_bearer | no | payer (por defecto) o merchant |
expires_at | no | ISO 8601. Sin esto, el enlace no caduca |
return_url | no | A dónde devolver al pagador al terminar. Tiene que ser https: |
metadata | no | Tus pares clave-valor. Te los devolvemos tal cual, aquí y en el webhook |
Si pides métodos concretos
El campo se llama accepted_methods y es una lista con los nombres de la
tabla de Métodos de cobro: ["SPEI", "MXNB"]. Cualquier otro
nombre de campo (methods, payment_methods…) se rechaza con
invalid_request y "property methods should not exist".
Hay dos formas de crear, y se comportan distinto si un método no se puede dejar listo en ese momento (por ejemplo, porque el banco no entregó una CLABE libre):
| Cómo lo creas | Si un método no queda listo |
|---|---|
Sin accepted_methods | Se crea el enlace con los que sí quedaron. Lee accepted_methods en la respuesta para saber cuáles son |
Con accepted_methods | No se crea. Recibes 400 payment_method_unavailable con param: "accepted_methods" y el método en el mensaje. Reintentar suele bastar |
Si necesitas SPEI sí o sí, pídelo explícitamente: así nunca te llega un enlace sin él creyendo que lo tiene.
La vuelta a tu tienda
Con return_url, la página de cobro puede devolver al pagador a donde tú digas
cuando termina. Sin ella se queda en la página de Binkio sin camino de vuelta.
Tiene que ser https:. No es formalismo: a esa URL se manda el navegador de
tu cliente desde una pantalla de pago, así que un http: degradaría a claro esa
navegación y un javascript: sería ejecutar código en el contexto del cobro.
Los dos se rechazan con 400.
Ponle lo que necesites para reconocer el pedido —/pedido/4821/gracias— porque
la URL se guarda tal cual y te la devolvemos en el detalle del enlace.
:::caution No la uses como confirmación de pago
El pagador vuelve a tu tienda cuando él termina, no cuando el dinero está
acreditado. Quien confirma es el webhook payment_link.paid, o
GET /v1/payment_links/{code} si prefieres preguntar tú.
:::
metadata — para no mantener una tabla aparte
El problema que resuelve: recibes el webhook de un cobro y necesitas saber a qué pedido tuyo corresponde. Sin un sitio donde guardarlo, mantienes tu propia tabla que traduce «código de enlace» → «pedido 4821» — y el día que se te desincroniza, por un enlace creado y no guardado o una fila borrada, te llega el aviso de un cobro real y no sabes de quién es.
Cuélgalo del enlace y te vuelve solo:
curl -X POST https://api.binkio.com/v1/payment_links \
-H "Authorization: Bearer bk_live_sec_…" \
-H "Idempotency-Key: pedido-4821" \
-H "Content-Type: application/json" \
-d '{
"amount": "1500.25",
"currency": "MXNB",
"description": "Pedido 4821",
"metadata": { "order_id": "4821", "sucursal": "centro" }
}'
Te lo devolvemos sin tocar en el detalle del enlace, en el listado, y en los
webhooks de payment_link.paid, payment_link.partial_deposit,
payment_link.closed y payment_link.claim_reported.
Va en la representación base, no sólo en el detalle: listar 25 cobros y casarlos con tus pedidos es una llamada, no veintiséis.
Los límites, y por qué
| Regla | Valor |
|---|---|
| Claves como máximo | 50 |
| Longitud de una clave | 40 caracteres |
| Longitud de un valor | 500 caracteres |
| Tipo de los valores | cadena, siempre |
Un valor que no sea cadena se rechaza, no se convierte. Si mandas
{"order_id": 4821} recibes un 400, no un "4821" silencioso. Convertir por
tu cuenta significaría que descubres la conversión leyendo tu propio webhook,
con la conciliación ya escrita encima.
Un solo nivel, sin objetos anidados. Un objeto anidado no tiene tope natural de tamaño, y esto viaja en cada respuesta y en cada webhook que te mandamos.
:::danger No metas datos personales
Esto acaba en tus registros, en los nuestros, en correos de soporte y en
consolas de gente que está depurando. Un identificador de pedido, sí. El correo,
el teléfono o la dirección de tu cliente, no — para eso ya tienes tu propia base
de datos, y el order_id es la llave para llegar a ella.
:::
:::tip {} no es lo mismo que no mandarla
Si no mandas metadata, el campo vuelve como null. Si mandas {}, vuelve
como {}. La diferencia se conserva a propósito, para que puedas distinguir
«nunca puse nada» de «la vacié».
:::
amount va como cadena
Y no es un capricho. JSON.parse convierte 999999999.12345678 en
999999999.1234568: la precisión se pierde antes de que el importe llegue a
nosotros. Mandar un número recibe un 400, no una conversión silenciosa — porque
un cobro con el importe equivocado se descubre cobrando.
Monedas
| Moneda | Qué es | Notas |
|---|---|---|
MXNB | Peso mexicano digital, 1:1 con el peso | El más común para cobrar en México |
USDC | Dólar digital | Para precios en dólares |
COP | Peso colombiano | Nativo 1:1, sin conversión. Sólo admite el método COP |
Métodos de cobro
| Método | El pagador manda | Riel |
|---|---|---|
SPEI | Pesos mexicanos, por transferencia | Banca mexicana |
WIRE | Dólares, por transferencia internacional | Banca corresponsal |
MXNB | MXNB en cadena | Arbitrum |
USDC | USDC en cadena | Ethereum |
USDT | USDT en cadena | Ethereum |
COP | Pesos colombianos | Rieles de Cobre: llave Bre-B, QR, Nequi, PSE, Bancolombia |
La combinación de currency y accepted_methods es lo que decide si hay
conversión. Un enlace en MXNB pagado por SPEI es 1:1 y no hay nada que
convertir. El mismo enlace pagado en USDC sí: el pagador manda dólares
digitales y tú recibes pesos digitales.
:::caution No todos los métodos caben en todos los importes
Cada método tiene un mínimo y un máximo, y cuando media una conversión esos
límites dependen de la ruta. Un método que no cabe se publica como no disponible
con el motivo (BELOW_MIN_THRESHOLD / ABOVE_MAX_THRESHOLD) en vez de
fallar cuando el pagador ya está intentando pagar.
:::
Pregunta antes de crear
Pedir un método que el importe no soporta devuelve 400. Para no averiguarlo
a base de errores, pregunta con el importe del carrito en la mano:
curl "https://api.binkio.com/v1/payment_links/payment_methods?currency=MXNB&amount=50000.00" \
-H "Authorization: Bearer bk_live_sec_…"
{
"object": "payment_method_options",
"currency": "MXNB",
"amount": "50000.00",
"available_methods": ["MXNB", "SPEI", "USDC", "WIRE", "USDT"],
"conversion_enabled": true,
"methods": [
{ "method": "SPEI", "available": true, "requires_conversion": false },
{ "method": "WIRE", "available": true, "requires_conversion": true }
]
}
No crea ni consulta ningún enlace. conversion_enabled te dice de un vistazo si
ese importe alcanza para cobrar también en la otra familia de moneda: con
false, sólo puedes cobrar en la moneda del enlace.
Quién paga la comisión
fee_bearer decide de dónde sale:
payer(por defecto) — el pagador manda el importe más la comisión, y tú recibes exactamente lo que pusiste enamount.merchant— el pagador mandaamounty la comisión se descuenta de lo que recibes.
Con payer, lo que ingresa tu cuenta es el importe publicado. Ni más ni menos:
si la tasa se mueve a favor durante un pago cruzado, ese sobrante no es tuyo ni
del pagador — es el margen que cubre el riesgo de la conversión.
El ciclo de vida
| Estado | Qué significa |
|---|---|
active | Esperando pago. Es el único estado en el que se puede pagar |
processing | El dinero llegó y se está acreditando (barrido en cadena o conversión) |
paid | Acreditado. paid_at y paid_with_method dicen cuándo y cómo |
cancelled | Lo cancelaste tú |
expired | Se pasó expires_at sin pagarse |
processing no es un estado transitorio de milisegundos. En un pago en
cadena hay que esperar confirmaciones, y en uno cruzado hay una conversión de por
medio. Enseña «estamos confirmando tu pago», no «pago fallido».
Sólo un enlace active se puede cancelar:
curl -X POST https://api.binkio.com/v1/payment_links/PL-9C2X-4T7B-1KD8/cancel \
-H "Authorization: Bearer bk_live_sec_…" \
-H "Idempotency-Key: cancela-4821"
Cancelar uno ya pagado devuelve 409 payment_link_not_cancellable.
Un enlace ya cancelado o caducado se puede borrar:
curl -X DELETE https://api.binkio.com/v1/payment_links/PL-9C2X-4T7B-1KD8 \
-H "Authorization: Bearer bk_live_sec_…" \
-H "Idempotency-Key: borra-4821"
Responde 204 sin cuerpo. Un enlace active hay que cancelarlo primero, y uno
paid no se borra nunca — es el respaldo de un cobro que ocurrió, y seguirá
en tus movimientos. Los dos casos devuelven 409 payment_link_not_deletable.
Consultar
# El listado, del más reciente al más antiguo, paginado por cursor
curl "https://api.binkio.com/v1/payment_links?status=paid&limit=50" \
-H "Authorization: Bearer bk_live_sec_…"
# Uno concreto, con el detalle de cada método
curl https://api.binkio.com/v1/payment_links/PL-9C2X-4T7B-1KD8 \
-H "Authorization: Bearer bk_live_sec_…"
Filtros del listado: status, currency, created_after, created_before,
limit y cursor.
Para el resumen —lo que pinta un panel— no hace falta recorrer el histórico:
curl https://api.binkio.com/v1/payment_links/stats \
-H "Authorization: Bearer bk_live_sec_…"
{
"object": "payment_link_stats",
"active": 12,
"processing": 2,
"paid": 340,
"cancelled": 7,
"expired": 23,
"total": 384
}
El detalle añade lo que el listado no lleva: un objeto por cada método aceptado, con su importe a pagar, su cotización y las instrucciones de envío.
{
"object": "payment_link",
"code": "PL-9C2X-4T7B-1KD8",
"status": "active",
"amount": "1500.25000000",
"currency": "MXNB",
"methods": [
{
"object": "payment_link_method",
"method": "SPEI",
"asset": "MXN",
"requires_conversion": false,
"amount_due": "1500.25",
"quote_status": "native",
"instructions": { "clabe": "000000000000000000" }
},
{
"object": "payment_link_method",
"method": "USDC",
"asset": "USDC",
"requires_conversion": true,
"amount_due": "89.42",
"exchange_rate": "17.0234",
"quote_status": "locked",
"amount_due_expires_at": "2026-09-07T10:29:22.481Z",
"instructions": { "address": "0x…", "network": "ethereum" }
}
]
}
quote_status te dice en qué situación está el importe de cada método:
| Valor | Qué significa |
|---|---|
native | Sin conversión. El importe no caduca |
locked | Con conversión, importe fijado hasta amount_due_expires_at |
expired | La cotización venció. Recárgala antes de enseñarla |
calculating | Todavía se está cotizando |
:::info La CLABE de SPEI caduca La CLABE de un enlace es un registro de un solo uso con el banco, y caduca (del orden de una semana). Si nadie paga antes:
- En
GET /v1/payment_links/{code}y en el checkout, el método SPEI deja de traerinstructions.clabe: no publicamos una CLABE vencida, porque el banco puede habérsela asignado ya a otro cobro. - Al abrir
checkout_url, la página de pago registra una CLABE nueva para el mismo enlace y el mismo importe. El pago se sigue atribuyendo a tu enlace.
Si montas tu propia pantalla de cobro con la API y el enlace tiene más de unos
días, manda al pagador a checkout_url o crea un enlace nuevo.
:::
:::warning Una cotización vencida no se refresca sola en el detalle
GET /v1/payment_links/{code} es de sólo lectura: si el bloqueo venció te lo
dice (quote_status: "expired", sin importe) pero no cotiza de nuevo. Es
deliberado — un GET que regenera la cotización puede cambiarle la CLABE al
pagador mientras la está copiando, y ese pago llega a una cuenta que ya nadie
mira. Para una cotización fresca, manda al pagador a checkout_url.
:::
El checkout del pagador
Tu cliente no tiene —ni puede tener— una credencial nuestra. El código del enlace es su credencial: sirve para un solo cobro, no da acceso a tu cuenta, y quien lo tiene sólo puede pagarte.
Por eso estas dos rutas no piden Authorization:
curl https://api.binkio.com/v1/checkout/PL-9C2X-4T7B-1KD8
curl https://api.binkio.com/v1/checkout/PL-9C2X-4T7B-1KD8/status
Te devuelven el nombre del comercio, el importe, los métodos con sus instrucciones y —esto es lo útil— los datos para enterarte en vivo de que se pagó, sin encuestar:
{
"object": "checkout",
"code": "PL-9C2X-4T7B-1KD8",
"status": "active",
"merchant": { "object": "checkout_merchant", "name": "Tienda Ejemplo" },
"realtime": {
"object": "checkout_realtime",
"transport": "socket.io",
"namespace": "/payment-links",
"join": { "event": "join", "payload": { "code": "PL-9C2X-4T7B-1KD8" } },
"notify_events": ["payment_link:paid", "payment_link:quotes_updated"],
"status_url": "https://api.binkio.com/v1/checkout/PL-9C2X-4T7B-1KD8/status",
"poll_interval_seconds": 5
}
}
Si prefieres no montar el socket, status_url cada poll_interval_seconds hace
el mismo trabajo con más latencia.
Usa esto si construyes tu propia pantalla de cobro. Si no, checkout_url ya
te da una hecha, con los métodos, el QR y la actualización en vivo resueltos.