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ó, recupéralo 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.

Catálogo de eventos

Transacciones

Cuándo Cuerpo Documentación
Cambia el estado de un pago { transaction, checkout } 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, 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 time subscription.finished_by_limit Se alcanzó el max_recurrences o el deadline del plan
canceled subscription.canceled Se canceló la suscripción
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

{danger} finished time lleva un espacio literal. No es un error de la documentación y no lo vamos a cambiar: hay integraciones que ya lo manejan. Si prefieres una clave limpia, usa meta.event_type.

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