# Webhooks --- - [Webhooks](#webhooks) - [Por qué necesitas un webhook](#por-que) - [Cómo se entrega](#entrega) - [Verificar la firma](#firma) - [Identificar cada entrega](#meta) - [Reintentos](#reintentos) - [Historial y reenvío](#historial) - [Catálogo de eventos](#eventos) - [Cómo debe ser tu endpoint](#tu-endpoint) - [Diagnóstico](#diagnostico) ## Por qué necesitas un webhook [#por-que] 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](/status-transaction) como respaldo. ## Cómo se entrega [#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](/generate-transaction) | | Suscripción | `webhook_url` de la [suscripción](/subscription) | | Reserva de cupo | `webhook_url` de la [reserva](/mit-pre-authorization), o el `result_urls.webhook` de las opciones avanzadas | | Herramientas del panel | En las opciones avanzadas del cobro | :::caution La URL tiene que aceptar `POST` y estar accesible desde internet. Una URL que solo responde a `GET` no recibe nada. ::: ## Verificar la firma [#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](https://sag.efipay.co/documentacion/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 | :::caution 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:** ```php 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. ```js 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:** ```python @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 ``` :::note Compara con una función de tiempo constante (`hash_equals`, `timingSafeEqual`, `compare_digest`), no con `==`. ::: ## Identificar cada entrega [#meta] 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`. ```json { "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 | :::tip **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`). ::: :::note `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 [#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](#historial) y reenvíalo, o recupera el estado con la [consulta de estado](/status-transaction). :::caution Reintentar significa que **el mismo evento puede llegarte varias veces**. Tu endpoint tiene que ser idempotente: deduplica por [`meta.event_id`](#meta) y, si ya lo procesaste, responde `2xx` sin repetir nada. ::: ## Historial y reenvío [#historial] Guardamos **cada webhook que enviamos** —suscripciones, transacciones y reservas de cupo— por su [`meta.event_id`](#meta), 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 [#historial-listar] **Descripción:** Lista los eventos enviados, del más reciente al más antiguo. Viene [paginado](/conventions#paginacion). | Parámetro | Descripción | | - | - | | `filter[event_type]` | Tipo exacto, p. ej. `subscription.renewed`. Ver [catálogo](#eventos) | | `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` | `GET /api/v1/events` :::tip Respuesta satisfactoria ::: Código de respuesta: 200 ```json { "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 [#historial-detalle] **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. `GET /api/v1/events/your-event-id` :::tip Respuesta satisfactoria ::: Código de respuesta: 200 ```json { "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 ::: Código de respuesta: 404 ```json { "message": "…", "error": { "type": "invalid_request_error", "code": "not_found", "message": "…", "param": null } } ``` ### Reenviar un evento [#historial-reenvio] **Descripción:** Vuelve a enviar el evento. Responde `202` con el evento, que vuelve a `status: "pending"`. El reenvío tiene sus propios [reintentos](#reintentos) automáticos si tu servidor vuelve a fallar, y cada intento se suma a los `attempts` del mismo evento. `POST /api/v1/events/your-event-id/resend` :::tip Reenvío aceptado ::: Código de respuesta: 202 ```json { "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](#firma), a la URL a la que se envió el evento. Los intentos nuevos se suman a los del mismo evento. :::caution **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](#meta) que usas para los reintentos. ::: ### Desde el panel [#historial-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 [#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](/webhook-transaction) | | Cambia el estado de un pago de Cobra Plus | `{ transaction, checkout }` con las respuestas del formulario | [Webhook Cobra Plus](/webhook-transaction-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](/subscription-webhook). ### 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](/mit-pre-authorization#webhooks). ## Cómo debe ser tu endpoint [#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`](#meta) 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. :::note **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 [#diagnostico] | 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 |