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-SHA256en hexadecimal, con tu secreto como clave, sobre la cadena:
${t}.${cuerpo_crudo}
Para verificarlo:
- Parte la cabecera y saca
tyv1. - Rechaza si
|ahora − t| > 300segundos. - Calcula el HMAC sobre
`${t}.${cuerpo_crudo}`con tu secreto. - Compáralo con
v1en 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": {
"link_code": "PL-9C2X-4T7B-1KD8",
"amount": "500.00",
"currency": "MXN",
"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
| Evento | Campos en data |
|---|---|
payment_link.paid | link_code, amount, currency, payment_method |
payment_link.partial_deposit | link_code, required_amount, received_amount, currency |
deposit.completed | deposit_id, amount, asset, method |
deposit.failed | deposit_id, amount, reason |
withdrawal.completed | withdrawal_id, amount, net_amount, fee, asset, method |
withdrawal.failed | withdrawal_id, amount, reason |
conversion.completed | conversion_id, from_asset, to_asset, from_amount, to_amount, exchange_rate, fee |
balance.updated | asset, new_balance, change, reason |
refund.completed | refund_id, payment_link_code, amount, asset |
refund.failed | refund_id, payment_link_code, amount, asset, reason |
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.
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:
| Intento | Cuándo |
|---|---|
| 1 | inmediato |
| 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
3xxcuenta como fallo: no seguimos redirecciones. Si mueves el endpoint, actualiza la URL registrada. - Cada intento tiene un límite de 10 segundos. Responde
2xxcuanto 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íntoma | Dónde mirar |
|---|---|
| Todas las firmas fallan | Casi seguro estás firmando el cuerpo re-serializado. Ver §2. |
| Algunas firmas fallan | Reloj 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 golpe | Mira disabled_at: puede que lo hayamos apagado tras cinco fallos. |
| No sé si nos llegó tu llamada | GET /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.