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.
Cada endpoint se registra en un modo, Prueba o Real, y solo recibe los eventos de ese modo: un cobro de prueba nunca llega al endpoint que despacha tus pedidos reales. Para recibir los dos, registra un endpoint en cada modo.
| Evento | Qué significa | Qué hacer |
|---|---|---|
payment.authorized | Solo en una preautorización (capture.mode: MANUAL): el emisor aprobó y reservó la plata, y tú decides cuándo capturarla. Con captura automática, la de defecto, el cobro te llega como payment.succeeded desde la autorización. | Captúralo cuando corresponda, o anúlalo. |
payment.succeeded | El cobro quedó aprobado. | Despacha el pedido. Es el único que lo autoriza. |
payment.failed | El emisor rechazó la tarjeta. | No despaches. Si trae decline, advice_action dice si conviene reintentar; si no, ofrece otro medio. |
payment.cancelled | Se anuló antes de capturar. | Libera el inventario reservado. |
payment.refunded | Se devolvió la plata. | Registra la devolución. |
payment.partially_refunded | Se devolvió una parte. El pago sigue como estaba. | Trae refunded_amount, lo que se devolvió esta vez. |
subscription.payment_failed | Falló un cobro recurrente. Trae la fecha del próximo intento y, en amount, lo que cobra ese intento, con el descuento que tenga. | Avísale al cliente; Lane va a reintentar. |
subscription.past_due | Se agotaron los reintentos y la suscripción quedó con el cobro pendiente. | Lane dejó de cobrar sola: contacta al cliente o cancela. Si la tienda eligió cancelar al agotarse, llega subscription.canceled en su lugar. |
subscription.created | Un cliente se suscribió. | Dale acceso. Trae el plan, el valor y el próximo cobro. |
subscription.paused | La suscripción quedó en pausa. | Trae resumes_at, la fecha en que se reanuda sola (vacía si la pausaste tú desde el panel), e initiated_by. |
subscription.resumed | Terminó una pausa. | Trae next_charge_at. initiated_by dice quién: subscriber, merchant, o system cuando la pausa venció sola. |
subscription.canceled | Se canceló: por el cliente, desde tu panel o porque se agotaron los reintentos de un cobro (initiated_by: system, reason: payment_failed). | Si trae ends_at, sigue activa hasta esa fecha: no le quites el acceso antes. Trae reason y comment. |
subscription.updated | Algo cambió en la suscripción: casi siempre el cliente desde su portal; el precio lo cambias tú. | change dice qué: charge_skipped, charge_rescheduled, frequency_changed, quantity_changed, plan_changed, price_changed, card_updated, shipping_address_changed, discount_applied o cancellation_undone. |
instrument.enrolled | Un cliente guardó su tarjeta. | Ya puedes cobrarle sin él presente. |
dispute.created | Un comprador desconoció un cobro: se abrió un contracargo. | Mira evidence_due_at y responde desde el panel antes de esa fecha. Trae id, status, amount, currency, reason_code, evidence_due_at y payment_id. |
dispute.updated | El contracargo cambió de estado. | Con status sabes en qué va. Los estados están en Disputas. |
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, y uno que no eligió ninguno los recibe todos.
| Cabecera | Contenido |
|---|---|
Lane-Event | El tipo de evento, por ejemplo payment.succeeded. |
Lane-Timestamp | Momento del envío, en segundos Unix. |
Lane-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("Lane-Signature") ?? "";
const evento = req.get("Lane-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("Lane-Signature", "")):
return "firma inválida", 400
encolar(request.headers.get("Lane-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_LANE_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
{
"id": "3f6c2b1a-…",
"type": "payment.succeeded",
"created": "2026-10-10T15:04:05.000Z",
"environment": "live",
"data": {
"payment": {
"id": "a02e5b19-…",
"amount": 11900000,
"currency": "COP",
"status": "succeeded",
"checkout_session_id": "c1f4a2de-…"
}
}
}id es el del aviso: el mismo aviso reintentado trae el mismo, y con él evitas procesarlo dos veces. El cobro va en data.payment.
environment dice en qué modo ocurrió: test si fue de prueba, live si fue real. Viene en el evento para que puedas comprobarlo antes de despachar.
Reconoces el pedido por data.payment.id o por checkout_session_id, que son los que te devolvió la API al crear el cobro. El metadata que mandaste no viaja en el aviso: vuelve intacto al consultar el cobro con GET /v1/payments/{id}.
Una plataforma recibe en sus propios endpoints los avisos de los cobros que creó a nombre de sus comercios, con connected_merchant_id, y los de sus comercios: merchant.connected, merchant.disconnected, merchant.approved y merchant.rejected. Están en Avisos de plataforma.
Cualquier respuesta fuera del rango 2xx —o un endpoint que no responde en 10 segundos, o una redirección, que no se sigue— 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.
Se deja de intentar 3 días después de creado el evento, sea cual sea la respuesta. La entrega queda como fallida y no vuelve a salir sola: cuando tu endpoint esté arreglado, reenvíala desde el panel.
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