# Webhooks

> Recibe los cambios de estado de tus cobros en tu servidor: catálogo de eventos de Lane, cómo verificar la firma HMAC y cómo funcionan los reintentos.

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

| 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](https://lane.money/docs/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

| 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>` |

## 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.

> **Importante: 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**

_Node_

```js
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);
}
```

_Python_

```python
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_

```php
<?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**
```json
{
  "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.
