Saltar al contenido principal

Webhooks

Cuando algo pasa en tu cuenta —te pagan un enlace, se acredita un depósito, sale un retiro— te mandamos un POST a la URL que nos digas, firmado, para que tu sistema se entere sin preguntar.


1. Registra tu destino​

Desde tu propio sistema, con el alcance webhooks:manage:

curl -X POST https://api.binkio.com/v1/webhook_endpoints \
-H "Authorization: Bearer bk_live_sec_7f3a…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.mitienda.mx/binkio/webhooks",
"description": "Producción",
"enabled_events": ["payment_link.paid", "deposit.completed"]
}'

La respuesta trae el secreto de firma, y es la única vez que lo vas a ver:

{
"object": "webhook_endpoint",
"id": "3e8b1d24-6f70-4c19-b2a5-0d9e7f4c6a13",
"url": "https://api.mitienda.mx/binkio/webhooks",
"enabled_events": ["payment_link.paid", "deposit.completed"],
"signing_secret": "whsec_7f3a9c2e5b1d8a406c93e2f7b5a1d4c8",
"enabled": true,
"live": true
}

:::danger Guárdalo al recibirlo signing_secret no se puede recuperar. Si lo pierdes hay que rotarlo — y rotar invalida el anterior de inmediato, así que despliega el nuevo antes de rotar o tus siguientes avisos llegarán firmados con algo que tu servidor va a rechazar. :::

Tu URL tiene que ser https y alcanzable desde internet. No aceptamos http, ni localhost, ni direcciones de red interna: el secreto protege la integridad del cuerpo, no lo cifra, y sobre http el importe de un cobro viaja en claro por la red de quien esté en medio.

Para probar tu integración sin esperar a un cobro real:

curl -X POST https://api.binkio.com/v1/webhook_endpoints/{id}/test \
-H "Authorization: Bearer bk_live_sec_7f3a…"

Te devuelve el desenlace real: si tu servidor contestó 500, lo verás con su código y su error, no un acuse de que lo intentamos.


2. La firma — el contrato​

Cada envío lleva esta cabecera:

Binkio-Signature: t=1754260123,v1=9f2a…(64 caracteres hex)
  • t — el momento del envío, en segundos Unix.
  • v1 — HMAC-SHA256 en hexadecimal, con tu secreto como clave, sobre la cadena:
${t}.${cuerpo_crudo}

Para verificarlo:

  1. Parte la cabecera y saca t y v1.
  2. Rechaza si |ahora − t| > 300 segundos.
  3. Calcula el HMAC sobre `${t}.${cuerpo_crudo}` con tu secreto.
  4. Compáralo con v1 en tiempo constante.

⚠️ El error número uno: volver a serializar el cuerpo​

Tienes que firmar los bytes exactos que llegaron, antes de parsear el JSON.

Si parseas y vuelves a serializar, cambian los espacios, el orden de las claves y los escapes — y la firma deja de cuadrar aunque el aviso sea auténtico. Es, con diferencia, lo que más tiempo hace perder al integrar, porque el síntoma («todas las firmas fallan») no apunta a la causa.

En Express, el cuerpo crudo se conserva así:

// ❌ Mal: el cuerpo ya viene parseado.
// `JSON.stringify(req.body)` NO son los bytes originales.
app.use(express.json());

// ✅ Bien: guarda el crudo para esta ruta.
app.post(
'/binkio/webhooks',
express.raw({ type: 'application/json' }),
(req, res) => {
const crudo = req.body.toString('utf8'); // los bytes tal cual llegaron

if (!verificar(crudo, req.get('Binkio-Signature'), process.env.BINKIO_WHSEC)) {
return res.sendStatus(400);
}

const evento = JSON.parse(crudo);
// Responde cuanto antes y haz el trabajo pesado aparte.
res.sendStatus(200);
},
);

Verificación de referencia​

Node.js — sin dependencias
const crypto = require('crypto');

function verificar(cuerpoCrudo, cabecera, secreto, toleranciaSegundos = 300) {
if (!cabecera) return false;

const partes = Object.fromEntries(
cabecera.split(',').map((p) => {
const i = p.indexOf('=');
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}),
);

const t = Number(partes.t);
if (!Number.isInteger(t)) return false;
if (!/^[0-9a-f]{64}$/.test(partes.v1 || '')) return false;

// Ventana de tolerancia: corta los reenvíos de un aviso capturado.
const ahora = Math.floor(Date.now() / 1000);
if (Math.abs(ahora - t) > toleranciaSegundos) return false;

const esperado = crypto
.createHmac('sha256', secreto)
.update(`${t}.${cuerpoCrudo}`, 'utf8')
.digest('hex');

// Comparación en tiempo constante: un `===` filtra, por lo que tarda,
// cuántos caracteres iniciales acertó quien lo intenta.
const a = Buffer.from(esperado, 'utf8');
const b = Buffer.from(partes.v1, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Python — sólo biblioteca estándar
import hashlib
import hmac
import re
import time


def verificar(cuerpo_crudo: bytes, cabecera: str, secreto: str,
tolerancia_segundos: int = 300) -> bool:
if not cabecera:
return False

partes = {}
for trozo in cabecera.split(","):
clave, _, valor = trozo.partition("=")
partes[clave.strip()] = valor.strip()

try:
t = int(partes["t"])
except (KeyError, ValueError):
return False

v1 = partes.get("v1", "")
if not re.fullmatch(r"[0-9a-f]{64}", v1):
return False

if abs(int(time.time()) - t) > tolerancia_segundos:
return False

firmado = f"{t}.".encode("utf-8") + cuerpo_crudo
esperado = hmac.new(secreto.encode("utf-8"), firmado, hashlib.sha256).hexdigest()

return hmac.compare_digest(esperado, v1)

En Flask, el cuerpo crudo es request.get_data(); en Django, request.body. En los dos casos, antes de tocar request.json.

Por qué el momento va dentro de la firma​

Si t viajara suelto, alguien que capture un aviso legítimo podría reenviarlo mañana cambiando sólo t, y la firma seguiría cuadrando. Firmándolo junto al cuerpo, cambiarlo invalida la firma — y no cambiarlo lo delata contra la ventana de 300 segundos.

:::caution Las otras cabeceras no van firmadas Binkio-Event-Id, Binkio-Event-Type y Binkio-Delivery-Attempt sirven para depurar y enrutar, pero no son de fiar: van fuera de la firma. Los mismos datos viajan dentro del cuerpo, que sí está firmado. Toma tus decisiones con lo de dentro. :::


3. El cuerpo​

{
"id": "evt_9f2a4c8b7e1d3a5f",
"type": "payment_link.paid",
"created": 1754260123,
"livemode": true,
"data": {
"code": "PL-9C2X-4T7B-1KD8",
"link_code": "PL-9C2X-4T7B-1KD8",
"amount": "500.00000000",
"currency": "MXNB",
"net_amount": "487.50000000",
"net_currency": "MXNB",
"payment_method": "SPEI"
}
}

Trata cada entrega como idempotente por id. La entrega es «al menos una vez»: aunque el id es determinista y una reemisión interna no te llega dos veces, un tiempo de espera agotado de tu lado puede provocar un reintento nuestro sobre algo que ya procesaste. Guarda los id que ya viste.

Catálogo de eventos​

EventoCampos en data
payment_link.paidcode, link_code, amount, currency, net_amount, net_currency, payment_method
payment_link.partial_depositcode, link_code, required_amount, received_amount, currency
payment_link.claim_reportedcode, amount, currency, payment_method, claim_count, metadata
payment_link.closedcode, link_code, amount, currency, reason
deposit.completeddeposit_id, amount, asset, method
deposit.faileddeposit_id, amount, reason
withdrawal.completedwithdrawal_id, amount, net_amount, fee, asset, method
withdrawal.failedwithdrawal_id, amount, reason
conversion.completedconversion_id, from_asset, to_asset, from_amount, to_amount, exchange_rate, fee
balance.updatedasset, new_balance, change, reason
refund.completedrefund_id, payment_link_code, amount, asset
refund.failedrefund_id, payment_link_code, amount, asset, reason

En los eventos de enlace: la clave, y los dos importes​

code y amount son los mismos que devuelve GET /v1/payment_links/{code} — misma clave, mismo importe, byte a byte. Es lo que te deja conciliar el aviso contra tu pedido sin traducir nada.

CampoQué es
codeEl enlace. La misma clave que en la API.
link_codeEl mismo valor. En desuso: existe porque hay integraciones que ya lo leen. En código nuevo usa code.
amount / currencyLo que pedía el enlace.
net_amount / net_currencyLo que se abonó al comercio, ya sin comisión ni IVA.

net_currency puede no ser la moneda del enlace: un enlace en MXNB que liquida en USDC abona USDC. Compara siempre importe y moneda juntos.

:::caution Si integraste antes de septiembre de 2026 amount traía entonces el neto, no el importe del enlace: un enlace de 104.00000000 MXNB llegaba como 90.59040000. Si tu código guardaba ese amount como el total del pedido, ahora recibe el que sí cuadra con la API — y el neto sigue disponible, en net_amount. :::

:::danger payment_link.claim_reported no es un cobro Alguien pulsó «ya pagué» en la página de cobro y el enlace sigue sin acreditar. No hay ni un peso comprobado detrás: puede ser impaciencia, o dinero que salió y no llegó a donde debía.

No entregues nada con este evento. El cobro de verdad llega en payment_link.paid. Éste sirve para saber que hay alguien esperando ese pedido y poder contestarle antes de que lo intente por su cuenta.

claim_count es el dato de triaje: 1 es un despiste, 4 es alguien a quien nadie ha contestado. Va silenciado por enlace —ventana de 30 minutos—, así que no recibes uno por pulsación por mucho que el pagador insista. :::

El catálogo vivo, con los campos de cada uno, está en GET /v1/webhook_endpoints/event_types. Consúltalo en vez de escribir los tipos a mano: un evento mal escrito se rechaza con 400, no en silencio.

Esa lista de campos es exhaustiva: no llega nada más, y no cambia sin aviso.

:::tip payment_link.closed es el desenlace contrario a paid Llega cuando un enlace deja de poder cobrar sin haberse pagado, y reason dice por qué: expired (se pasó la fecha) o cancelled (lo cancelaste tú).

Es la señal para soltar lo que apartaste al crear el enlace — inventario, saldo de lealtad, una reserva. Sin él tendrías que sondear GET /v1/payment_links/{code} en bucle para enterarte de que un pago se abandonó.

Un solo evento y no dos porque la reacción es la misma; lo que cambia, y por eso va en reason, es si no llegó el pagador o si te echaste atrás tú. :::

Lo que deliberadamente no te mandamos​

Quién pagó, el nombre del ordenante, la clave de rastreo, el hash de la transacción, la dirección de origen, la CLABE del pagador y la referencia del proveedor no viajan en el aviso.

Son datos de terceros, y un webhook llega a un servidor sin que nadie haya pedido nada. Si los necesitas, pídelos a la API con tu credencial: así queda registrado quién los consultó y cuándo.


4. Entregas y reintentos​

El primer intento sale de inmediato, en cuanto el aviso queda registrado. Te enteras de un cobro en segundos.

Si tu servidor no contesta bien, reintentamos con esta escalera:

IntentoCuándo
1inmediato
2+1 min
3+5 min
4+15 min
5+60 min
6+180 min

Después de seis intentos, la entrega queda como fallida y no se reintenta más.

  • Se considera entregado con cualquier 2xx.
  • Un 3xx cuenta como fallo: no seguimos redirecciones. Si mueves el endpoint, actualiza la URL registrada.
  • Cada intento tiene un límite de 10 segundos. Responde 2xx cuanto antes y haz el trabajo pesado aparte — si procesas el evento antes de contestar, un proceso lento se convierte en un reintento innecesario.

:::warning Cinco fallos seguidos apagan el destino Cinco entregas agotadas consecutivas desactivan el destino. Lo verás en el campo disabled_at, con el motivo en disabled_reason, y dejarás de recibir avisos hasta que lo vuelvas a encender con POST /v1/webhook_endpoints/{id}/enable.

Arregla tu servidor antes de reactivarlo: si sigue rechazando, se apaga otra vez. Una entrega correcta pone el contador a cero. :::

Fíjate en que son dos cosas distintas: enabled es tu interruptor —lo apagas y lo enciendes tú— y disabled_at es cuándo lo apagamos nosotros. El campo live te dice si, sumando las dos, estás recibiendo avisos ahora mismo.

No dependas sólo de los avisos​

Un webhook es la vía rápida, no la fuente de verdad. Para lo que tenga que cuadrar —contabilidad, conciliación, cierres— contrasta contra la API: GET /v1/transactions y GET /v1/payment_links son el estado real de tu cuenta.

Es el mismo consejo que daríamos aunque la entrega fuera perfecta: tu servidor puede estar caído justo cuando pasa algo, y ninguna escalera de reintentos dura para siempre.


5. Cuando algo no cuadra​

SíntomaDónde mirar
Todas las firmas fallanCasi seguro estás firmando el cuerpo re-serializado. Ver §2.
Algunas firmas fallanReloj desincronizado: la ventana es de 300 s. Sincroniza por NTP.
No llega ningún aviso¿El destino está live? ¿El evento está en enabled_events?
Dejaron de llegar de golpeMira disabled_at: puede que lo hayamos apagado tras cinco fallos.
No sé si nos llegó tu llamadaGET /v1/request_logs — si no aparece ahí, no nos llegó.

Manda un POST /v1/webhook_endpoints/{id}/test para ver el desenlace real —código HTTP y error incluidos— sin esperar a que ocurra un cobro.