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ó, 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_idy, si ya lo procesaste, responde2xxsin repetir nada.
| 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 |
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 timelleva 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, usameta.event_type.
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 |