Webhooks


Por qué necesitas un webhook

Cuando tu cliente paga, el resultado ocurre fuera de tu petición: en el checkout, en el banco, en la red. Tu servidor no está ahí para verlo.

El webhook es cómo te lo contamos: cuando el estado de un pago cambia, hacemos un POST a la URL que nos diste con el estado nuevo.

Es el mecanismo principal, no un extra. Consultar el estado en bucle es más lento, más frágil y te va a hacer marcar como fallidos pagos que se aprobaron cinco segundos después. Usa el webhook y deja la consulta de estado como respaldo.

Cómo se entrega

  • Método POST con Content-Type: application/json.
  • Cuerpo en JSON, distinto según el evento (ver el catálogo).
  • Header Signature con la firma del cuerpo.
  • Tiempo de espera: 3 segundos. Si tu endpoint tarda más, cuenta como fallo.

Dónde configuras la URL, según el caso:

Caso Dónde va la URL
Pago generado por API advanced_options.result_urls.webhook al generar el pago
Suscripción webhook_url de la suscripción
Reserva de cupo webhook_url de la reserva, o el result_urls.webhook de las opciones avanzadas
Herramientas del panel En las opciones avanzadas del cobro

{warning} La URL tiene que aceptar POST y estar accesible desde internet. Una URL que solo responde a GET no recibe nada.

Verificar la firma

Verifica siempre la firma antes de hacer nada con el cuerpo. Sin eso, cualquiera que conozca tu URL puede decirte que le aprobaron un pago que nunca hizo.

La firma es un HMAC-SHA256 del cuerpo crudo, con tu token de webhooks como clave:

Signature = hash_hmac('sha256', cuerpo_crudo_tal_como_llegó, tu_token_de_webhooks)

El resultado es hexadecimal en minúsculas, sin prefijo: 64 caracteres, nada de sha256= delante.

Tu token de webhooks está en Documentación → API key. Es distinto del token de la API.

Alcance Uno por comercio. No hay un token por sede: todas las sedes firman con el mismo
Ambientes El mismo en prueba y en producción. El ambiente lo decide el token de la API, no el de webhooks
Rotación Hoy no se puede rotar desde el panel

{warning} La URL no entra en la firma: se firma solo el cuerpo. Si publicas varias URLs de webhook, todas verifican con el mismo token.

{danger} Firma sobre el cuerpo crudo, no sobre el JSON re-serializado. Si decodificas y vuelves a codificar, el orden de las claves o los espacios cambian y la firma nunca va a coincidir.

PHP / Laravel:

Route::post('/webhooks/efipay', function (Illuminate\Http\Request $request) {
    $expected = hash_hmac('sha256', $request->getContent(), config('services.efipay.webhook_token'));

    abort_unless(hash_equals($expected, (string) $request->header('Signature')), 403);

    // Recién aquí es seguro leer el cuerpo.
    $payload = $request->json()->all();

    // ... procesa y responde rápido
    return response()->noContent();
});

Node / Express — nota el express.raw: con express.json pierdes el cuerpo original y la firma no cuadra.

app.post('/webhooks/efipay',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const expected = require('crypto')
      .createHmac('sha256', process.env.EFIPAY_WEBHOOK_TOKEN)
      .update(req.body)
      .digest('hex');

    const received = req.get('Signature') ?? '';

    if (expected.length !== received.length ||
        !require('crypto').timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
      return res.sendStatus(403);
    }

    const payload = JSON.parse(req.body);
    // ... procesa y responde rápido
    res.sendStatus(204);
  });

Python / Flask:

import hmac, hashlib

@app.post('/webhooks/efipay')
def efipay_webhook():
    expected = hmac.new(
        WEBHOOK_TOKEN.encode(), request.get_data(), hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, request.headers.get('Signature', '')):
        return '', 403

    payload = request.get_json()
    # ... procesa y responde rápido
    return '', 204

{info} Compara con una función de tiempo constante (hash_equals, timingSafeEqual, compare_digest), no con ==.

Identificar cada entrega

Todo webhook trae un bloque meta. Va dentro del cuerpo, así que la firma lo cubre: un tercero no puede reescribirlo sin invalidar el Signature.

{
    "meta": {
        "event_id": "9b1f0e34-6b2a-4a7e-9d0a-3c5f2b7a1d80",
        "event_type": "subscription.renewed",
        "timestamp": "2026-09-07T10:32:16-05:00",
        "api_version": "v1"
    }
}
Campo Para qué sirve
event_id La llave para deduplicar. Es el mismo en todos los reintentos de una entrega, y distinto entre eventos
event_type Clave estable del evento. Úsala en vez de status.key, que arrastra valores heredados
timestamp Momento en que generamos el evento. Sirve para descartar entregas viejas
api_version Versión del contrato del cuerpo

{success} Cómo deduplicar. Guarda el event_id la primera vez que proceses un evento. Si vuelve a llegar, responde 2xx y no hagas nada más. Funciona para todos los eventos, incluidos los que no traen transaction (canceled, paused, resumed, trial_will_end, plan_changed).

{info} timestamp viene en hora de Colombia con desplazamiento explícito (-05:00). Si rechazas entregas antiguas, deja una ventana holgada: un reintento legítimo puede llegar horas después del timestamp original.

Reintentos

Si tu endpoint no responde 2xx —o tarda más de 3 segundos— reintentamos:

Intento Cuándo Acumulado
1 De inmediato —
2 10 segundos después 10 s
3 1 min 40 s después ~2 min
4 15 minutos después ~17 min
5 1 hora después ~1 h 17 min
6 4 horas después ~5 h 17 min
7 12 horas después ~17 h

Después del séptimo no hay más intentos. La ventana es de casi un día entero, así que un despliegue o una caída corta de tu endpoint ya no pierden el evento. Si aun así se perdió, búscalo en el historial y reenvíalo, o recupera el estado con la consulta de estado.

{warning} Reintentar significa que el mismo evento puede llegarte varias veces. Tu endpoint tiene que ser idempotente: deduplica por meta.event_id y, si ya lo procesaste, responde 2xx sin repetir nada.

Historial y reenvío

Guardamos cada webhook que enviamos —suscripciones, transacciones y reservas de cupo— por su meta.event_id, con todos sus intentos de entrega y el código HTTP que respondió tu endpoint. El historial se conserva 30 días.

Los siete reintentos automáticos (~17 h) cubren una caída corta. Si tu endpoint estuvo caído más tiempo, lista los eventos con filter[status]=failed y reenvíalos, o léelos uno a uno para conciliar sin esperar la entrega.

Los endpoints usan el mismo token Bearer que el resto de la API y solo ven los eventos de tu comercio en el ambiente del token: con un token de prueba ves los eventos de prueba, y con uno de producción los de producción.

Listar eventos

Descripción: Lista los eventos enviados, del más reciente al más antiguo. Viene paginado.

Parámetro Descripción
filter[event_type] Tipo exacto, p. ej. subscription.renewed. Ver catálogo
filter[status] pending (en curso o por reintentar), succeeded (tu endpoint respondió 2xx) o failed (se agotaron los intentos)
filter[subscription_id] Eventos de una suscripción
filter[transaction_id] Eventos de una transacción, por su transaction_id numérico
filter[search] Busca por event_id, subscription_id o transaction_id
filter[created_from] / filter[created_to] Rango de fechas de creación, Y-m-d
sort created_at, -created_at (por defecto) o last_attempt_at

{success} Respuesta satisfactoria

code: 200

{
    "data": [
        {
            "id": "9b1f0e34-6b2a-4a7e-9d0a-3c5f2b7a1d80",
            "event_type": "subscription.renewed",
            "status": "failed",
            "url": "https://tu-comercio.com/webhooks/efipay",
            "subscription_id": "9b0f2a13-2c44-4a1a-9f2e-1d3b4c5d6e7f",
            "transaction_id": 20481,
            "attempts_count": 7,
            "last_http_status": 503,
            "last_attempt_at": "2026-09-08T03:32:16-05:00",
            "delivered_at": null,
            "created_at": "2026-09-07T10:32:16-05:00"
        }
    ],
    "links": { "…": "…" },
    "meta": { "current_page": 1, "per_page": 15, "…": "…" }
}
Campo Qué es
id El meta.event_id del webhook
event_type Tipo del evento
status pending, succeeded o failed
url URL a la que se envió
subscription_id / transaction_id A qué pertenece el evento, si aplica
attempts_count Intentos hechos hasta ahora
last_http_status Código HTTP que respondió tu endpoint en el último intento
last_attempt_at / delivered_at / created_at Fechas en ISO 8601 con desplazamiento

Consultar un evento

Descripción: Devuelve un evento con los mismos campos del listado, más payload —exactamente el cuerpo firmado que enviamos— y attempts, cada intento de entrega.

{success} Respuesta satisfactoria

code: 200

{
    "id": "9b1f0e34-6b2a-4a7e-9d0a-3c5f2b7a1d80",
    "event_type": "subscription.renewed",
    "status": "failed",
    "…": "mismos campos del listado",
    "payload": {
        "meta": { "event_id": "9b1f0e34-6b2a-4a7e-9d0a-3c5f2b7a1d80", "event_type": "subscription.renewed", "…": "…" },
        "…": "resto del cuerpo enviado"
    },
    "attempts": [
        {
            "attempt": 1,
            "succeeded": false,
            "http_status": 503,
            "error_type": null,
            "error_message": null,
            "duration_ms": 412,
            "created_at": "2026-09-07T10:32:16-05:00"
        },
        {
            "attempt": 2,
            "succeeded": false,
            "http_status": null,
            "error_type": "timeout",
            "error_message": "Connection timed out after 3000 milliseconds",
            "duration_ms": 3001,
            "created_at": "2026-09-07T10:32:26-05:00"
        }
    ]
}

http_status es null cuando tu endpoint no llegó a responder (por ejemplo, un timeout); la causa queda en error_type y error_message.

{danger} El evento no existe, o no es de tu comercio o del ambiente del token

code: 404

{
    "message": "…",
    "error": {
        "type": "invalid_request_error",
        "code": "not_found",
        "message": "…",
        "param": null
    }
}

Reenviar un evento

Descripción: Vuelve a enviar el evento. Responde 202 con el evento, que vuelve a status: "pending". El reenvío tiene sus propios reintentos automáticos si tu servidor vuelve a fallar, y cada intento se suma a los attempts del mismo evento.

{success} Reenvío aceptado

code: 202

{
    "id": "9b1f0e34-6b2a-4a7e-9d0a-3c5f2b7a1d80",
    "event_type": "subscription.renewed",
    "status": "pending",
    "…": "mismos campos de la consulta, con payload y attempts"
}

El reenvío manda el mismo payload, con el mismo meta.event_id y el mismo esquema de firma, a la URL a la que se envió el evento. Los intentos nuevos se suman a los del mismo evento.

{warning} Un reenvío no es un evento nuevo. Si ya procesaste ese event_id, tu endpoint debe responder 2xx sin repetir nada: es la misma deduplicación que usas para los reintentos.

Desde el panel

En Documentación → Webhooks ves el mismo historial, con pestañas Producción y Pruebas, filtro por estado y búsqueda por event_id, suscripción o transacción. El detalle muestra los intentos y el payload, y la acción Reenviar hace lo mismo que el endpoint. Reenviar requiere el permiso completo de documentación (sag documentation: *).

Catálogo de eventos

Transacciones

Cuándo Cuerpo Documentación
Cambia el estado de un pago { transaction, checkout }. En un rechazo, transaction.error trae {code, message, retryable, action}; tu payment.metadata llega en checkout.payment_referenceable.metadata Webhook de transacción
Cambia el estado de un pago de Cobra Plus { transaction, checkout } con las respuestas del formulario Webhook Cobra Plus

Suscripciones

El cuerpo trae subscription (con tu metadata, y la del plan y el suscriptor), un objeto status con la clave heredada del evento, y transaction solo cuando hubo cobro.

status.key meta.event_type Cuándo
new subscription.created Se creó la suscripción y su primer cobro fue exitoso
renew subscription.renewed El cobro recurrente salió bien y la suscripción sigue activa
retry subscription.payment_retry El cobro falló; se reintentará y la suscripción sigue activa
finished subscription.finished No se pudo renovar y la gracia se agotó; queda inactiva
finished_by_limit subscription.finished_by_limit Se alcanzó el max_recurrences o el deadline del plan
canceled subscription.canceled Se canceló la suscripción. Si fue al final del período, sigue activa con cancel_at_period_end: true
ended subscription.ended La suscripción cancelada dejó de dar servicio (llegó cancel_at). Llega una sola vez; status pasa a cancelada
paused subscription.paused Se pausaron los cobros
resumed subscription.resumed Se reanudaron los cobros
plan_changed subscription.plan_changed Se aplicó un cambio de plan
trial_will_end subscription.trial_will_end La prueba está por terminar
renewed subscription.invitation_accepted El suscriptor aceptó una invitación de renovación o reactivación

Detalle en Webhook de suscripción.

Reservas de cupo

El cuerpo trae event y pre_authorization.

event Cuándo
mit.pre_authorized El cliente autorizó: hay fondos retenidos
mit.declined La red rechazó la reserva
mit.confirmed Se aplicó el cobro final
mit.voided Se liberó la reserva
mit.expiring_soon La reserva vence pronto y aún no la cobraste
mit.expired La reserva venció y el cupo se liberó solo

Detalle en Reserva de cupo.

Cómo debe ser tu endpoint

  1. Verifica la firma. Si no cuadra, responde 403 y no proceses nada.
  2. Responde rápido. Tienes 3 segundos. Guarda el evento y procésalo en segundo plano; no hagas el envío del pedido dentro de la petición del webhook.
  3. Sé idempotente. Usa meta.event_id como llave: si ya lo procesaste, responde 2xx sin repetir nada. Sirve para todos los eventos, también para los que no traen transaction.
  4. Confía en el estado que llega, no en el orden. Los eventos pueden llegar desordenados. Decide con el status del cuerpo, no con la secuencia.
  5. No expongas el token. Léelo de tu configuración, nunca de la petición.

{info} Lista de IPs de origen. Todavía no publicamos un rango fijo desde el que salen las entregas. El control de autenticidad es la firma, que es criptográfica y no depende de la red: una lista de permitidos por IP sería un refuerzo, no un reemplazo. Si tu política de seguridad la exige, escríbenos.

Diagnóstico

Síntoma Causa habitual
No llega nada La URL no está en result_urls.webhook / webhook_url, o no acepta POST, o no es accesible desde internet
La firma nunca cuadra Estás firmando el JSON re-serializado en vez del cuerpo crudo, o usas el token de la API en vez del de webhooks
Llega varias veces Es lo esperado: tu endpoint respondió algo distinto de 2xx, o tardó más de 3 s
Llega y se pierde igual Tu endpoint responde 2xx pero falla al procesar. Guarda primero, procesa después