# Webhook de suscripción --- - [Webhook de suscripción](#webhook) - [Qué te avisamos](#overview) - [Cómo se entrega y cómo se verifica](#entrega) - [Forma del cuerpo](#cuerpo) - [Catálogo de eventos](#eventos) - [Cuándo llega cada uno](#semantica) - [Sobre los identificadores](#ids) - [Detalle de los objetos](#detalle-data) - [Ejemplos por evento](#ejemplos) ## Qué te avisamos [#overview] Una suscripción cobra sola, mes tras mes, **fuera de cualquier petición tuya**. El webhook es cómo te contamos qué pasó: que se renovó, que el cobro falló, que el cliente la canceló, que dejó de dar servicio, que la prueba está por terminar. Configura la URL en el campo `webhook_url` al [crear la suscripción](/subscription#create), o en `advanced_options.result_urls.webhook` del [plan](/plan). :::caution Si el plan tiene `result_urls.webhook`, esa URL **gana** sobre el `webhook_url` que mandes al crear la suscripción. ::: ## Cómo se entrega y cómo se verifica [#entrega] Igual que todos nuestros webhooks: `POST` con `Content-Type: application/json`, header `Signature` con el HMAC-SHA256 del cuerpo crudo, 3 segundos de espera y hasta 7 intentos repartidos en unas 17 horas. Si tu endpoint estuvo caído más tiempo, el evento sigue en el [historial de webhooks](/webhooks#historial) durante 30 días y puedes reenviarlo con el mismo `meta.event_id`. **El detalle completo, con ejemplos de verificación en PHP, Node y Python, está en [Webhooks](/webhooks).** Aquí solo va lo específico de suscripciones. :::danger Verifica la firma antes de leer el cuerpo. Sin eso, cualquiera que conozca tu URL puede decirte que se renovó una suscripción que nunca se cobró. ::: ## Forma del cuerpo [#cuerpo] ```json { "meta": { "event_id": "…", "event_type": "…", "timestamp": "…", "api_version": "v1" }, "subscription": { "…": "estado completo de la suscripción" }, "status": { "key": "renew", "value": "Renovado", "description": "…" }, "transaction": { "…": "solo cuando hubo cobro" } } ``` | Bloque | Siempre viene | Qué es | | - | - | - | | `meta` | Sí | Identificador de la entrega. Ver [Identificar cada entrega](/webhooks#meta) | | `subscription` | Sí | El estado **resultante** de la suscripción, ya aplicado el evento | | `status` | Sí | El **evento**: qué acaba de pasar | | `transaction` | No | Solo en los eventos que implican un cobro | | `previous_plan_id` | No | Solo en `plan_changed`: el plan que tenía antes | :::tip **No necesitas una consulta extra.** `subscription` ya trae `status`, `ends_at`, `grace_ends_at`, `on_grace_period`, `is_active`, `cancel_at_period_end`, `cancel_at`, `paused_at`, `resumes_at`, `metadata` y `next_charge`. Con eso decides sin volver a llamarnos. ::: :::note No confundas los dos `status`. El de primer nivel describe **el evento** (`renew`, `canceled`…). El de dentro de `subscription` es **el estado de la suscripción** (`trial`, `activa`, `en_gracia`, `pausada`, `cancelada`, `finalizada`), calculado en el momento del envío igual que en el `GET`; ver [Estados](/subscription#estados). ::: ## Catálogo de eventos [#eventos] | `status.key` | `meta.event_type` | Trae `transaction` | | - | - | - | | `new` | `subscription.created` | Sí | | `renew` | `subscription.renewed` | Sí | | `retry` | `subscription.payment_retry` | Sí | | `finished` | `subscription.finished` | Sí | | `finished_by_limit` | `subscription.finished_by_limit` | No | | `canceled` | `subscription.canceled` | No | | `ended` | `subscription.ended` | No | | `paused` | `subscription.paused` | No | | `resumed` | `subscription.resumed` | No | | `plan_changed` | `subscription.plan_changed` | Depende del flujo | | `trial_will_end` | `subscription.trial_will_end` | No | | `renewed` | `subscription.invitation_accepted` | Sí, si el cobro se ejecutó | ## Cuándo llega cada uno [#semantica] Las dudas que más nos llegan, respondidas: **¿Qué evento marca el final por falta de pago?** `finished`. La secuencia completa de un cobro que falla es: `retry` en cada intento mientras la suscripción siga viva, y `finished` cuando se agota la ventana de gracia sin haber cobrado. No hay un evento aparte para "se acabó la gracia". **¿`retry` llega una vez o en cada intento?** En **cada** intento de cobro fallido. La cadencia de reintentos la define el plan (ventana de gracia) y el dunning del comercio. **¿Siempre se reintenta mientras quede gracia?** No. La causal del rechazo manda sobre la ventana de gracia: un rechazo que no puede aprobar en otro intento corta el dunning de inmediato y el evento que llega es `finished`, no `retry`, aunque `grace_ends_at` siga en el futuro. Ver [Rechazos que no se reintentan](#rechazos-duros). **¿En qué se diferencian `renew`, `renewed` y `plan_changed`?** | | Quién lo dispara | Qué pasó | | - | - | - | | `renew` | Nosotros, en el cobro automático | Se cobró el ciclo y la suscripción sigue | | `renewed` | El suscriptor | Aceptó una invitación de renovación o reactivación que le llegó por correo | | `plan_changed` | El comercio o el suscriptor | Se cambió de plan, directo con prorrateo o aceptando la invitación | **¿`paused` y `resumed` pueden originarse de nuestro lado?** No. Solo los disparan `POST /subscription/{id}/pause` y `/resume`, que llamas tú o alguien desde el panel del comercio. Efipay no pausa suscripciones por su cuenta. **Después de cancelar, ¿puede llegarme un `renew`?** No. El motor de cobro excluye toda suscripción con `canceled_at`, así que en cuanto la cancelación queda registrada no hay más cobros ni más `renew`. Lo que sí sigue vigente hasta `cancel_at` es el servicio, salvo que canceles con [`at_period_end: false`](/subscription#cancel). **¿Cómo sé cuándo cortar el acceso de una suscripción cancelada?** Con `ended` (`subscription.ended`). Al cancelar al final del período recibes `canceled`, pero la suscripción sigue `activa` (o `en_gracia`) con `cancel_at_period_end: true`. Cuando deja de dar servicio —al llegar `ends_at` o, si hay gracia posterior, el fin de la gracia—, un proceso que corre **cada hora** envía `ended`, **una sola vez** por suscripción. En ese momento `subscription.status` es `cancelada`, `cancel_at_period_end` es `false` e `is_active` es `false`. También llega tras una cancelación inmediata (`at_period_end: false`) y tras `finished_by_limit` (límite de cobros alcanzado). Como Stripe con `customer.subscription.deleted`. ## Sobre los identificadores [#ids] Es la confusión más común, porque **conviven dos tipos**: | Campo | Tipo | Ejemplo | | - | - | - | | `subscription.id` | **UUID** | `9ad70d0e-61e1-4c17-99c3-355b50d79954` | | `subscription.plan.id` | **UUID** | `9ae4dc2d-8b2e-4920-ac1f-80f2d648a2e6` | | `subscription.subscriber.id` | **UUID** | `9ae92bbb-2cab-4569-85b1-6173d0fb9d4b` | | `transaction.transaction_id` | **Entero** | `108` | | `office.id`, `commerce_id` | **Entero** | `1` | :::note Las suscripciones, planes, suscriptores y grupos han sido UUID **desde el principio**. No existen suscripciones antiguas con id numérico que haya que migrar. Lo numérico es el consecutivo de la transacción y los ids de sede y comercio. ::: :::caution Guárdalos todos como **cadena**, también los numéricos. Un entero de transacción puede crecer más allá de lo que tu lenguaje maneja con seguridad, y un UUID parseado como número se corrompe en silencio. ::: ## Detalle de los objetos [#detalle-data] ### `subscription` | Campo | Descripción | | - | - | | `id` | UUID de la suscripción | | `status` | Estado actual: `trial`, `activa`, `en_gracia`, `pausada`, `cancelada`, `finalizada` | | `starts_at` | Inicio del período vigente | | `ends_at` | Fin del período vigente | | `grace_ends_at` | Hasta cuándo se puede reintentar el cobro sin cortar el servicio | | `on_grace_period` | `true` si está dentro de la ventana de gracia ahora mismo | | `trial_ends_at` | Fin del período de prueba, si lo hay | | `ends_at_iso` | El mismo `ends_at` en ISO 8601 con offset (`2026-10-07T00:00:00-05:00`) | | `canceled_at` | Cuándo se canceló. `null` si no está cancelada | | `cancel_at_period_end` | `true` si está cancelada pero **todavía da servicio** hasta `cancel_at` | | `cancel_at` | ISO 8601 con offset: cuándo deja de dar servicio una suscripción cancelada (`ends_at`, o el fin de la gracia si es posterior). `null` si no está cancelada | | `paused_at` / `resumes_at` | Pausa de cobros y cuándo se reanuda (`null` = indefinida) | | `is_active` / `is_inactive` | Si sigue dando servicio | | `is_ended` / `is_expired` | Si terminó el período / si terminó sin gracia disponible | | `on_trial` / `on_discount` | Si está en prueba / con descuento vigente | | `production` | `false` en pruebas | | `description` | Tu descripción de la suscripción | | `metadata` | Tus pares clave-valor. Ver [Metadata](/subscription#metadata) | | `webhook_url` | La URL a la que te estamos escribiendo | | `plan` | Objeto del plan, con su `metadata` | | `subscriber` | Objeto del suscriptor, con su `metadata` | | `next_charge` | Próximo cobro programado | | `office` | Sede | ### `plan` | Campo | Descripción | | - | - | | `name` | Nombre del plan | | `description` | Descripción | | `price` | Precio del ciclo | | `currency_type` | Moneda | | `invoice_period` / `invoice_interval` | Cada cuánto se cobra | | `trial_period` / `trial_interval` | Duración de la prueba | | `grace_period` / `grace_interval` | Duración de la gracia | | `metadata` | Tus pares clave-valor del plan | ### `subscriber` | Campo | Descripción | | - | - | | `identification_type` | Tipo de documento | | `id_number` | Número de documento | | `name` / `last_name` | Nombre y apellido | | `email` | Correo | | `phone_code` / `cellphone_number` | Indicativo y celular | | `billing_address` / `billing_city` / `billing_country` | Datos de facturación | | `metadata` | Tus pares clave-valor del suscriptor | ### `next_charge` | Campo | Descripción | | - | - | | `amount` | Monto del próximo cobro, con el prorrateo ya sumado si hubo un cambio de plan con `create_prorations` | | `currency_type` | Moneda | | `next_renew_at` | Día del próximo cobro, `Y-m-d` en Colombia. Mismo valor que `next_transaction` y que `next-transaction.next_renew_at` (antes llegaba como medianoche UTC) | | `charge_scheduled_at` | ISO 8601 con offset: cuándo corre el proceso diario que lo cobra, las 19:00 de Colombia de ese día. El `renew` confirma el cobro | | `proration_amount` | Parte del monto que viene de un prorrateo. `null` o `0` si no hay | ### `transaction` Es el mismo objeto que devuelve el [checkout por API](/checkout-transaction): incluye `transaction_id`, `amount`, `status`, `status_key`, `response_code`, `error`, `card` y `transaction_details`. En un rechazo (`retry`, `finished`), `error` es `{code, message, retryable, action}`; ver [Códigos de error](/error-codes#red). ### `status` | Campo | Descripción | | - | - | | `key` | Identificador heredado del evento (ver [catálogo](#eventos)) | | `value` | Nombre corto para mostrar (`Renovado`, `Reintento de Pago`…) | | `description` | Frase explicativa | ## Ejemplos por evento [#ejemplos] ### `renew` — el cobro recurrente salió bien ```json { "meta": { "event_id": "9b1f0e34-6b2a-4a7e-9d0a-3c5f2b7a1d80", "event_type": "subscription.renewed", "timestamp": "2026-09-07T10:32:16-05:00", "api_version": "v1" }, "subscription": { "id": "9ad70d0e-61e1-4c17-99c3-355b50d79954", "status": "activa", "starts_at": "2026-09-07 00:00:00", "ends_at": "2026-10-07 00:00:00", "ends_at_iso": "2026-10-07T00:00:00-05:00", "grace_ends_at": "2026-10-10 00:00:00", "on_grace_period": false, "trial_ends_at": null, "canceled_at": null, "cancel_at_period_end": false, "cancel_at": null, "paused_at": null, "resumes_at": null, "is_active": true, "is_inactive": false, "on_trial": false, "on_discount": false, "canceled": false, "is_ended": false, "is_expired": false, "production": true, "description": null, "metadata": { "user_id": "42" }, "webhook_url": "https://tu-sistema.com/webhooks/efipay", "plan": { "id": "9ae4dc2d-8b2e-4920-ac1f-80f2d648a2e6", "name": "Plan mensual", "price": 49900, "currency_type": "COP", "invoice_period": 1, "invoice_interval": "month", "metadata": null }, "subscriber": { "id": "9ae92bbb-2cab-4569-85b1-6173d0fb9d4b", "identification_type": "CC", "id_number": "1020304050", "name": "Ana", "last_name": "Rojas", "email": "ana@correo.com", "metadata": { "crm_id": "C-881" } }, "next_charge": { "amount": 49900, "currency_type": "COP", "next_renew_at": "2026-10-07", "charge_scheduled_at": "2026-10-07T19:00:00-05:00", "proration_amount": null }, "office": { "id": 1, "name": "Principal" } }, "transaction": { "transaction_id": 90210, "amount": "49900.00", "currency_type": "COP", "payment_method": "credit", "payment_method_source": "Visa", "status": "Aprobada", "status_key": "approved", "response_code": "00", "error": null, "card": { "franchise": "Visa", "bin": "453210", "last_four": "7890" }, "approved_at": "2026-09-07 10:32:16" }, "status": { "key": "renew", "value": "Renovado", "description": "El pago ha sido exitoso, la suscripción fue renovada y seguirá activa" } } ``` ### Rechazos que no se reintentan [#rechazos-duros] Reintentar un cobro sobre una tarjeta que el emisor mandó retener, que está reportada como robada o que venció no puede aprobar nunca: solo suma rechazos y castiga la tasa de aprobación del comercio. Por eso el reintento depende de la causal: | Causal del rechazo | Códigos | Qué pasa | | - | - | - | | Transitoria (fondos, límites, emisor no disponible, timeout) | `51`, `61`, `65`, `68`, `90`, `91`, `93`, `96`, `05`, `92`, … | Se reintenta dentro de la ventana de gracia → `retry` | | Tarjeta inservible (retenida, robada, restringida, vencida, bloqueada) | `04`, `07`, `14`, `33`, `34`, `35`, `36`, `41`, `43`, `46`, `54`, `62`, `70`, `83`, `5C`, `9G` | Se corta el dunning y además la tarjeta guardada queda bloqueada → `finished` | | Transacción o dato inválido | `03`, `12`, `13`, `16`, `57`, `58`, `63`, `76`–`81`, `98` | Se corta el dunning → `finished` | `05` y `92` se reintentan a propósito: el emisor los usa tanto para una tarjeta bloqueada como para un timeout de red, y cortar un cobro por un timeout sería peor que reintentarlo. Cuando la tarjeta guardada queda bloqueada, el comercio la ve marcada en el listado de suscriptores y puede desbloquearla desde ahí si el tarjetahabiente resuelve el problema con su banco. ### `retry` — el cobro falló y se reintentará ```json { "meta": { "event_id": "…", "event_type": "subscription.payment_retry", "timestamp": "…", "api_version": "v1" }, "subscription": { "id": "9ad70d0e-61e1-4c17-99c3-355b50d79954", "status": "en_gracia", "ends_at": "2026-09-07 00:00:00", "grace_ends_at": "2026-09-10 00:00:00", "on_grace_period": true, "is_active": true }, "transaction": { "transaction_id": 90211, "status": "Rechazada", "status_key": "rejected", "response_code": "51", "error": { "code": "51", "message": "Transacción declinada. Fondos insuficientes", "retryable": false, "action": "contact_issuer" } }, "status": { "key": "retry", "value": "Reintento de Pago", "description": "El pago no fue exitoso, se volverá a intentar el pago, la suscripción sigue activa" } } ``` :::caution `on_grace_period: true` con `is_active: true` significa **sigue dando servicio**, aunque el cobro haya fallado. No cortes el acceso todavía: espera a `finished`. ::: ### `finished` — se agotó la gracia sin cobrar ```json { "meta": { "event_id": "…", "event_type": "subscription.finished", "timestamp": "…", "api_version": "v1" }, "subscription": { "id": "9ad70d0e-61e1-4c17-99c3-355b50d79954", "status": "finalizada", "on_grace_period": false, "is_active": false, "is_expired": true }, "transaction": { "transaction_id": 90215, "status": "Rechazada", "status_key": "rejected" }, "status": { "key": "finished", "value": "Suscripción Finalizada", "description": "La suscripción no pudo ser renovada, la suscripción esta inactiva, no se seguirán haciendo cobros" } } ``` ### `canceled` — sin cobro, sin objeto `transaction` ```json { "meta": { "event_id": "…", "event_type": "subscription.canceled", "timestamp": "…", "api_version": "v1" }, "subscription": { "id": "9ad70d0e-61e1-4c17-99c3-355b50d79954", "status": "activa", "canceled": true, "canceled_at": "2026-09-07 11:04:00", "ends_at": "2026-10-07 00:00:00", "cancel_at_period_end": true, "cancel_at": "2026-10-07T00:00:00-05:00", "is_active": true }, "status": { "key": "canceled", "value": "Suscripción Cancelada", "description": "La suscripción ha sido cancelada por el comercio, permanecerá activa hasta el fin del período actual" } } ``` :::note Fíjate en `status: "activa"` e `is_active: true` con `canceled_at` ya puesto: está cancelada pero el cliente conserva el servicio hasta `cancel_at` (`cancel_at_period_end: true`). Con [`at_period_end: false`](/subscription#cancel), `ends_at` sería igual a `canceled_at`, `status` vendría en `cancelada` e `is_active` en `false`. El corte de servicio lo confirma el evento `ended`. ::: ### `ended` — la suscripción cancelada dejó de dar servicio ```json { "meta": { "event_id": "…", "event_type": "subscription.ended", "timestamp": "…", "api_version": "v1" }, "subscription": { "id": "9ad70d0e-61e1-4c17-99c3-355b50d79954", "status": "cancelada", "canceled": true, "canceled_at": "2026-09-07 11:04:00", "ends_at": "2026-10-07 00:00:00", "cancel_at_period_end": false, "cancel_at": "2026-10-07T00:00:00-05:00", "is_active": false }, "status": { "key": "ended", "value": "Suscripción Finalizada", "description": "La suscripción cancelada llegó al fin de su período y dejó de dar servicio." } } ``` Llega **una vez** por suscripción, en la primera pasada del proceso horario después de `cancel_at`. Es el momento de cortar el acceso. ### `paused` y `resumed` ```json { "meta": { "event_id": "…", "event_type": "subscription.paused", "timestamp": "…", "api_version": "v1" }, "subscription": { "id": "9ad70d0e-61e1-4c17-99c3-355b50d79954", "status": "pausada", "paused_at": "2026-09-07 11:20:00", "resumes_at": "2026-12-01 00:00:00" }, "status": { "key": "paused", "value": "Suscripción Pausada", "description": "Los cobros de la suscripción han sido pausados." } } ``` ### `plan_changed` — con el plan anterior ```json { "meta": { "event_id": "…", "event_type": "subscription.plan_changed", "timestamp": "…", "api_version": "v1" }, "subscription": { "id": "9ad70d0e-61e1-4c17-99c3-355b50d79954", "status": "activa", "plan": { "id": "9b0100aa-1111-2222-3333-444455556666", "name": "Plan anual", "price": 499000 } }, "previous_plan_id": "9ae4dc2d-8b2e-4920-ac1f-80f2d648a2e6", "status": { "key": "plan_changed", "value": "Plan Cambiado", "description": "El plan de la suscripción fue cambiado con prorrateo." } } ``` ### `trial_will_end` — aviso previo al primer cobro ```json { "meta": { "event_id": "…", "event_type": "subscription.trial_will_end", "timestamp": "…", "api_version": "v1" }, "subscription": { "id": "9ad70d0e-61e1-4c17-99c3-355b50d79954", "status": "trial", "on_trial": true, "trial_ends_at": "2026-09-10 00:00:00", "next_charge": { "amount": 49900, "currency_type": "COP", "next_renew_at": "2026-09-10" } }, "status": { "key": "trial_will_end", "value": "Prueba por finalizar", "description": "El período de prueba de la suscripción está por finalizar." } } ```