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.
POST con Content-Type: application/json.Signature con la firma del cuerpo.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
POSTy estar accesible desde internet. Una URL que solo responde aGETno recibe nada.
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==.
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_idla primera vez que proceses un evento. Si vuelve a llegar, responde2xxy no hagas nada más. Funciona para todos los eventos, incluidos los que no traentransaction(canceled,paused,resumed,trial_will_end,plan_changed).
{info}
timestampviene 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 deltimestamporiginal.
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_idy, si ya lo procesaste, responde2xxsin repetir nada.
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.
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:
{
"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 |
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:
{
"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:
{
"message": "…",
"error": {
"type": "invalid_request_error",
"code": "not_found",
"message": "…",
"param": null
}
}
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:
{
"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 responder2xxsin repetir nada: es la misma deduplicación que usas para los reintentos.
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: *).
| 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 |
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.
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.
403 y no proceses nada.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.status del cuerpo, no con la secuencia.{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.
| 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 |