services/api/src/modules/payments/payments.webhooks.js
Overview
Este archivo define el router de webhooks de pagos para Stripe. Su responsabilidad es recibir eventos firmados, validar su autenticidad, evitar reprocesos duplicados y sincronizar el estado interno de PaymentOrder con el estado real de Stripe.
El router forma parte del módulo de pagos y trabaja junto a services/api/src/modules/payments/providers/stripe.provider.js, services/api/src/modules/payments/payments.service.js, services/api/src/models/PaymentOrder.js y services/api/src/models/StripeWebhookEvent.js. En el arranque de la API, esta ruta se monta antes de express.json() para conservar el body crudo que Stripe necesita para verificar la firma .
[!IMPORTANT] Esta ruta depende del payload crudo y del header
stripe-signature. Si el cuerpo ya fue transformado por un parser JSON, la verificación de firma deja de ser válida fileciteturn0file17turn0file3.
How it works
flowchart TD
Req[Stripe webhook request] --> Router[payments webhooks router]
Router --> Sig{stripe signature header}
Sig -->|missing| R400[400 response]
Sig -->|present| Verify[constructWebhookEvent]
Verify --> DupCheck[StripeWebhookEvent exists]
DupCheck -->|already processed| R200Dup[200 duplicate response]
DupCheck -->|new event| Type{event type}
Type -->|checkout session completed| Completed[Load PaymentOrder and confirm payment]
Type -->|checkout session expired| Expired[Mark PaymentOrder cancelled]
Type -->|customer subscription events| SubEvent[Resolve current subscription]
Type -->|invoice payment events| Invoice[Reload subscription and sync]
SubEvent --> Retrieve[retrieveSubscription]
Retrieve -->|resource missing and deleted| Snapshot[event data object]
Retrieve --> Sync[syncSubscriptionFromStripe]
Snapshot --> Sync
Invoice --> RetrieveSub[retrieveSubscription]
RetrieveSub --> Sync
Completed --> ConfirmCampaign[confirmPaymentAndActivateCampaign]
Completed --> ConfirmPlan[confirmPaymentAndUpgradePlan]
Expired --> Cancel[PaymentOrder status cancelled]
Sync --> S200[200 response]
ConfirmCampaign --> S200
ConfirmPlan --> S200
Cancel --> S200
El flujo arranca leyendo stripe-signature. Si falta, el router responde 400. Si existe, llama a constructWebhookEvent(req.body, signature) para validar la firma y construir el evento de Stripe.
Después aplica idempotencia con StripeWebhookEvent.exists({ eventId: event.id }). Si el evento ya se procesó, responde 200 con duplicate: true y no repite efectos. Si es nuevo, decide la rama según event.type.
Flujo para checkout.session.completed
- Lee
session.metadata.paymentOrderId. - Si no existe, responde
200sin continuar. - Busca la orden en
PaymentOrder. - Si el tipo es
plan, recupera la suscripción real cuando existesession.subscription. - Si no es plan, confirma la orden como campaña.
Flujo para checkout.session.expired
- Lee
paymentOrderIddesdesession.metadata. - Si existe, marca la orden como
cancelled. - No ejecuta confirmación ni activación.
Flujo para suscripciones
- Para
customer.subscription.created,updatedydeleted, el código no usa directamente el snapshot del webhook. - Primero llama a
retrieveSubscriptionpara leer el estado vivo en Stripe. - Después sincroniza ese estado con
syncSubscriptionFromStripe.
Flujo para facturas
- Para
invoice.payment_succeededyinvoice.payment_failed, el archivo leeinvoice.subscription. - Si existe, recupera la suscripción real con
retrieveSubscription. - Luego reutiliza
syncSubscriptionFromStripepara converger al mismo estado interno.
Key components
{
"title": "isResourceMissingError",
"content": "Detecta el caso oficial de Stripe en el que un recurso no se puede recuperar. Solo devuelve verdadero cuando el error tiene code resource_missing y statusCode 404.",
"type": "function",
"filePath": "services/api/src/modules/payments/payments.webhooks.js",
"badges": []
}
{
"title": "resolveCurrentSubscriptionForEvent",
"content": "Resuelve la Subscription actual para eventos de suscripci\u00f3n. Intenta leer la suscripci\u00f3n viva y solo usa el snapshot del webhook en customer.subscription.deleted cuando Stripe ya no permite recuperar el recurso.",
"type": "function",
"filePath": "services/api/src/modules/payments/payments.webhooks.js",
"badges": []
}
Este archivo no exporta servicios de dominio. Su trabajo es coordinar validación, deduplicación y despacho hacia funciones ya existentes del módulo de pagos.
Data & fields
Los datos que consume este router pertenecen al contrato de webhooks de Stripe. El archivo usa campos concretos del header, del evento y de los objetos anidados que Stripe envía en cada caso.
[
{
"name": "stripe-signature",
"type": "string",
"required": true,
"description": "Header firmado que Stripe envía para verificar el webhook."
},
{
"name": "req.body",
"type": "Buffer",
"required": true,
"description": "Cuerpo crudo del webhook. Se pasa a constructWebhookEvent sin parseo previo."
},
{
"name": "event.id",
"type": "string",
"required": true,
"description": "Identificador público del evento. Se usa para idempotencia en StripeWebhookEvent."
},
{
"name": "event.type",
"type": "string",
"required": true,
"description": "Tipo del evento de Stripe. Determina qué rama de procesamiento se ejecuta."
},
{
"name": "event.data.object",
"type": "object",
"required": true,
"description": "Snapshot firmado del objeto asociado al evento."
},
{
"name": "event.data.object.id",
"type": "string",
"required": true,
"description": "Identificador del recurso principal del evento. En suscripciones, es el subscription id."
},
{
"name": "session.metadata.paymentOrderId",
"type": "string",
"description": "Identificador interno de la PaymentOrder asociado al checkout."
},
{
"name": "session.subscription",
"type": "string",
"description": "ID de la suscripción creada por Stripe cuando el checkout produce una suscripción real."
},
{
"name": "invoice.subscription",
"type": "string",
"description": "ID de la suscripción asociada a la factura. Si no existe, el webhook se ignora."
}
]
Campos de persistencia usados por el router
PaymentOrder.status[badge:read-only]string— se consulta y se actualiza indirectamente para detectar órdenes pagadas o canceladas.StripeWebhookEvent.eventId[badge:read-only]string— almacena el identificador del evento ya procesado.StripeWebhookEvent.type[badge:read-only]string— guarda el tipo del evento recibido.PaymentOrder.type[badge:read-only]string— distingue entre órdenes deplany de campaña.PaymentOrder.campaignId[badge:read-only]string— se usa cuando el evento termina activando una campaña.PaymentOrder.planTarget[badge:read-only]string— se usa cuando el evento termina confirmando un plan.
Behaviors
isResourceMissingError(error)— devuelvetruesolo cuando Stripe marca el error comoresource_missingy404.resolveCurrentSubscriptionForEvent(event)— intenta recuperar la suscripción en vivo y usa el snapshot solo en la cancelación definitiva.router.post("/stripe", ...)— procesa el webhook de Stripe y responde con200,400o500según el resultado.
Comportamiento por evento
-
checkout.session.completed- Busca
paymentOrderIdenmetadata. - Si falta, responde
200y no hace nada más. - Consulta
PaymentOrderpara decidir si confirma un plan o una campaña. - Si existe
session.subscription, recupera la suscripción real antes de confirmar el plan.
- Busca
-
checkout.session.expired- Cancela la orden asociada.
- No llama a la capa de confirmación.
-
customer.subscription.created- Recupera la suscripción viva.
- Sincroniza el estado local.
-
customer.subscription.updated- Recupera la suscripción viva.
- Sincroniza el estado local.
-
customer.subscription.deleted- Recupera la suscripción viva.
- Solo usa el snapshot si Stripe ya no permite leer el recurso.
-
invoice.payment_succeeded- Recupera la suscripción asociada a la factura.
- Sincroniza el estado local.
-
invoice.payment_failed- Recupera la suscripción asociada a la factura.
- Sincroniza el estado local.
API
POST /api/payments/webhooks/stripe
{
"title": "Stripe webhook",
"description": "Procesa eventos firmados de Stripe y sincroniza PaymentOrder y suscripciones. Requiere body crudo y el header stripe-signature.",
"method": "POST",
"baseUrl": "<ServiceBaseUrl>/api/payments/webhooks",
"endpoint": "/stripe",
"headers": [
{
"key": "stripe-signature",
"value": "<firma>",
"required": true
}
],
"queryParams": [],
"pathParams": [],
"bodyType": "none",
"requestBody": "",
"formData": [],
"rawBody": "",
"responses": {
"200": {
"description": "Evento recibido, ignorado por duplicado, o procesado sin error.",
"body": "{\n \"received\": true\n}"
},
"400": {
"description": "Falta el header de firma o la firma no es v\u00e1lida.",
"body": "{\n \"error\": \"Falta stripe-signature.\"\n}"
},
"500": {
"description": "Fall\u00f3 el procesamiento interno del evento.",
"body": "{\n \"error\": \"Error interno al procesar el evento.\"\n}"
}
}
}
El endpoint no acepta JSON parseado como contrato propio. El router espera el body crudo que llega desde la capa montada en services/api/src/app.js, donde la ruta de webhooks se registra antes de express.json() .
Gotchas & edge cases
[!WARNING] Si falta
stripe-signature, el router responde400de inmediato. No intenta procesar el body.
[!WARNING]
constructWebhookEventdepende del cuerpo crudo. Si el body ya fue consumido por un parser JSON, la validación puede fallar fileciteturn0file17turn0file3.
[!WARNING]
customer.subscription.createdycustomer.subscription.updatedno usan el snapshot firmado como fuente principal. Siempre intentan leer el estado real en Stripe.
[!IMPORTANT] El fallback de
resolveCurrentSubscriptionForEventsolo aplica acustomer.subscription.deletedcuando Stripe responderesource_missingcon404. No se usa para errores de red, permisos o autenticación.
[!NOTE] El archivo usa
StripeWebhookEvent.existspara evitar reprocesar eventos ya confirmados. Si dos requests compiten, elE11000al crear el registro no se considera error real.
[!NOTE] Cuando
checkout.session.completedno traemetadata.paymentOrderId, el router responde200y termina sin efectos secundarios.
[!IMPORTANT] Los eventos de factura solo se procesan si
invoice.subscriptionexiste. Un cargo único sin suscripción queda fuera de esta lógica.
Related
Este archivo conecta con componentes muy concretos del módulo de pagos. La relación está limitada a los elementos que aparecen en el código o en el contexto verificado.
services/api/src/modules/payments/providers/stripe.provider.js— exponeconstructWebhookEventyretrieveSubscription.services/api/src/modules/payments/payments.service.js— exponeconfirmPaymentAndActivateCampaign,confirmPaymentAndUpgradePlanysyncSubscriptionFromStripe.services/api/src/models/PaymentOrder.js— permite leer el tipo de orden y cancelarla encheckout.session.expired.services/api/src/models/StripeWebhookEvent.js— registra la idempotencia porevent.id.services/api/src/app.js— monta este router antes deexpress.json()para mantener el body crudo de Stripe .
[!NOTE] En el contexto del módulo de pagos, este archivo representa la frontera de entrada para confirmaciones externas de Stripe. La confirmación de negocio ocurre después, en
payments.service.jsy en los modelos persistidos asociados fileciteturn0file3turn0file6.