Lane · Documentación

Plataforma

Webhooks

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.

Los eventos

EventoQué significaQué hacer
payment.succeededEl cobro quedó aprobado.Despacha el pedido. Es el único que lo autoriza.
payment.failedEl emisor rechazó la tarjeta.No despaches. Ofrece otro medio.
payment.cancelledSe anuló antes de capturar.Libera el inventario reservado.
payment.refundedSe devolvió la plata.Registra la devolución.
subscription.payment_failedFalló un cobro recurrente. Trae la fecha del próximo intento.Avísale al cliente; Lane va a reintentar.
subscription.past_dueSe agotaron los reintentos.Lane dejó de cobrar sola: contacta al cliente o cancela.
instrument.enrolledUn 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.

La petición

CabeceraContenido
Blink-EventEl tipo de evento, por ejemplo payment.succeeded.
Blink-TimestampMomento del envío, en segundos Unix.
Blink-Signaturet=<timestamp>,v1=<hmac>

Verificar la firma

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 el request.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);

El cuerpo del evento

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.

Reintentos

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.

  • Responde 200 antes de procesar. Encola el trabajo pesado.
  • Trata el mismo evento dos veces como si fuera uno: puede llegar repetido.
  • Un evento que no reconoces se responde 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.

Probar sin exponer tu máquina

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