Webhooks
Por qué necesitas un webhook
Sección titulada «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
Sección titulada «Cómo se entrega»- Método
POSTconContent-Type: application/json. - Cuerpo en JSON, distinto según el evento (ver el catálogo).
- Header
Signaturecon 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 |
Verificar la firma
Sección titulada «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 |
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 '', 204Identificar cada entrega
Sección titulada «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 |
Reintentos
Sección titulada «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.
Historial y reenvío
Sección titulada «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
Sección titulada «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 |
{ "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
Sección titulada «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.
{ "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.
{ "message": "…", "error": { "type": "invalid_request_error", "code": "not_found", "message": "…", "param": null }}Reenviar un evento
Sección titulada «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.
{ "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.
Desde el panel
Sección titulada «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
Sección titulada «Catálogo de eventos»Transacciones
Sección titulada «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
Sección titulada «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
Sección titulada «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
Sección titulada «Cómo debe ser tu endpoint»- Verifica la firma. Si no cuadra, responde
403y no proceses nada. - 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.
- Sé idempotente. Usa
meta.event_idcomo llave: si ya lo procesaste, responde2xxsin repetir nada. Sirve para todos los eventos, también para los que no traentransaction. - Confía en el estado que llega, no en el orden. Los eventos pueden llegar
desordenados. Decide con el
statusdel cuerpo, no con la secuencia. - No expongas el token. Léelo de tu configuración, nunca de la petición.
Diagnóstico
Sección titulada «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 |