Plataforma
La única forma correcta de saber que un cobro se aprobó. Todo lo demás es adivinar.
Registra una URL en Desarrolladores → Webhooks y elige qué eventos quieres. Cuando pasa algo, mandamos un POST con el evento. Al crear el endpoint se muestra una vez un secreto whsec_…: con ese secreto verificas que el aviso lo mandamos nosotros.
| Evento | Qué significa | Qué hacer |
|---|---|---|
payment.succeeded | El cobro quedó aprobado. | Despacha el pedido. Es el único que lo autoriza. |
payment.failed | El emisor rechazó la tarjeta. | No despaches. Ofrece otro medio. |
payment.cancelled | Se anuló antes de capturar. | Libera el inventario reservado. |
payment.refunded | Se devolvió la plata. | Registra la devolución. |
subscription.payment_failed | Falló un cobro recurrente. Trae la fecha del próximo intento. | Avísale al cliente; Lane va a reintentar. |
subscription.past_due | Se agotaron los reintentos. | Lane dejó de cobrar sola: contacta al cliente o cancela. |
instrument.enrolled | Un cliente guardó su tarjeta. | Ya puedes cobrarle sin él presente. |
Los eventos de pago se nombran payment.<estado>, con el mismo vocabulario de estados. Suscríbete solo a los que vas a manejar: un endpoint que responde error a un evento que no le importa entra igual en la cola de reintentos.
| Cabecera | Contenido |
|---|---|
Blink-Event | El tipo de evento, por ejemplo payment.succeeded. |
Blink-Timestamp | Momento del envío, en segundos Unix. |
Blink-Signature | t=<timestamp>,v1=<hmac> |
La firma es un HMAC-SHA256 en hexadecimal sobre el texto <timestamp>.<cuerpo>, con el secreto del endpoint como llave. El punto va entre los dos: no es una concatenación directa.
El cuerpo crudo, no el objeto. Hay que firmar el texto exacto que llegó. Si tu framework ya parseó el JSON y lo vuelves a serializar, el orden de las llaves y los espacios pueden cambiar, y la firma no va a cuadrar nunca. En Express,express.raw(); en Next.js, lee elrequest.text().
Verificación
import crypto from "node:crypto";
import express from "express";
const app = express();
app.post(
"/webhooks/lane",
express.raw({ type: "application/json" }), // el cuerpo crudo, sin parsear
(req, res) => {
const firma = req.get("Blink-Signature") ?? "";
const evento = req.get("Blink-Event");
const cuerpo = req.body.toString("utf8");
if (!firmaValida(cuerpo, firma, process.env.LANE_WEBHOOK_SECRET)) {
return res.status(400).send("firma inválida");
}
// Responde rápido y procesa después: si tardas, entramos a reintentar.
res.sendStatus(200);
encolar(evento, JSON.parse(cuerpo));
},
);
function firmaValida(cuerpo, cabecera, secreto) {
const partes = Object.fromEntries(
cabecera.split(",").map((p) => p.split("=")),
);
const { t, v1 } = partes;
if (!t || !v1) return false;
// La firma cubre "<timestamp>.<cuerpo>", no solo el cuerpo.
const esperada = crypto
.createHmac("sha256", secreto)
.update(`${t}.${cuerpo}`)
.digest("hex");
const a = Buffer.from(esperada);
const b = Buffer.from(v1);
if (a.length !== b.length) return false;
// Comparación en tiempo constante: una comparación normal filtra la firma
// carácter por carácter a quien mida cuánto tarda en fallar.
return crypto.timingSafeEqual(a, b);
}import hmac, hashlib, os
from flask import Flask, request
app = Flask(__name__)
@app.post("/webhooks/lane")
def lane():
cuerpo = request.get_data(as_text=True) # crudo, sin parsear
if not firma_valida(cuerpo, request.headers.get("Blink-Signature", "")):
return "firma inválida", 400
encolar(request.headers.get("Blink-Event"), request.get_json())
return "", 200
def firma_valida(cuerpo: str, cabecera: str) -> bool:
partes = dict(p.split("=", 1) for p in cabecera.split(",") if "=" in p)
t, v1 = partes.get("t"), partes.get("v1")
if not t or not v1:
return False
esperada = hmac.new(
os.environ["LANE_WEBHOOK_SECRET"].encode(),
f"{t}.{cuerpo}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(esperada, v1)<?php
$cuerpo = file_get_contents("php://input");
$cabecera = $_SERVER["HTTP_BLINK_SIGNATURE"] ?? "";
parse_str(str_replace(",", "&", $cabecera), $partes);
$t = $partes["t"] ?? "";
$v1 = $partes["v1"] ?? "";
$esperada = hash_hmac("sha256", "$t.$cuerpo", getenv("LANE_WEBHOOK_SECRET"));
if (!$t || !hash_equals($esperada, $v1)) {
http_response_code(400);
exit("firma inválida");
}
http_response_code(200);payment.succeeded
{
"type": "payment.succeeded",
"data": {
"id": "a02e5b19-…",
"amount": 11900000,
"currency": "COP",
"status": "succeeded",
"metadata": { "order_id": "4821" }
}
}El metadata que mandaste al crear el cobro vuelve intacto. Es la forma de reconocer de qué pedido habla el evento sin guardar una tabla de equivalencias.
Cualquier respuesta fuera del rango 2xx —o un timeout— pone la entrega en cola. La espera crece con cada intento: 30 segundos, 1 minuto, 2, 4, y de ahí en adelante 5 minutos fijos.
200 antes de procesar. Encola el trabajo pesado.200 igual. Ignorarlo no es un error.Cada intento queda registrado con su código de respuesta y su cuerpo. Puedes verlos y reenviar una entrega fallida desde Desarrolladores → Webhooks.
En desarrollo, apunta el endpoint a un túnel HTTPS hacia tu equipo. Cuando termines, borra ese endpoint: una URL que ya no existe acumula entregas fallidas.
Esta página en markdown: /docs/webhooks.md · Toda la documentación: /docs/llms-full.txt