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.
  • v1HMAC-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": {
"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

EventoCampos en data
payment_link.paidlink_code, amount, currency, payment_method
payment_link.partial_depositlink_code, required_amount, received_amount, currency
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

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:

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.