Cada rama de Amplify necesita su propio webhook de Stripe

Los endpoints de webhook de Stripe en modo de prueba: el del sandbox con 13 eventos, uno obsoleto de example.com tachado y nada para las ramas staging y main de Amplify, junto a las dos rutas de SSM de las que se lee un secreto de Gen 2 en cada arranque en frío

Estaba cerrando un programa de referidos para Back to Fluency, mi app para aprender idiomas. La funcionalidad depende de tres eventos de Stripe que la app nunca había escuchado: invoice.paid para ganar una recompensa, y charge.dispute.created / charge.dispute.closed para anularla o restituirla. Código terminado, tests en verde, la prueba de punta a punta contra el sandbox en verde. Quedaba una línea en la lista de pendientes del pull request: registrar los tres eventos nuevos en las ramas desplegadas.

Esa línea me llevó más que las tres últimas tareas de la funcionalidad juntas, porque cuando fui a añadir tres eventos a los endpoints de staging y de producción, no había endpoints a los que añadirlos. Ninguna de las dos ramas había recibido nunca un webhook de Stripe. Nada había fallado, así que nada me lo había dicho.

TL;DR

  1. En Amplify Gen 2, un handler de webhooks se despliega mejor como una Lambda con Function URL. Cada rama despliega su propia copia, así que cada rama tiene su propia URL, y Stripe necesita un endpoint por rama, cada uno con su propio secreto de firma.
  2. La URL del sandbox acaba en tu amplify_outputs.json local, así que el sandbox se configura. Las URLs de las ramas desplegadas acaban en outputs de CloudFormation que nadie lee, así que no.
  3. Un endpoint que falta no hace ruido. El checkout funciona, porque es la app llamando a Stripe. Cada evento que Stripe envía después no tiene adónde ir.
  4. Un secret() se lee de SSM en cada arranque en frío: primero la ruta de la rama, después la ruta compartida de toda la app. Un valor a nivel de rama sustituye al compartido y no requiere redesplegar.
  5. El secreto de firma solo existe en la respuesta que crea el endpoint. Escríbelo directamente en SSM desde el mismo proceso; nunca lo pegues.

Por qué el webhook es una Lambda y no un route handler

El sitio obvio para un webhook de Stripe en una app de Next.js es app/api/webhooks/stripe/route.ts. La app empezó ahí, y esa ruta sigue en el repositorio, obsoleta. Puede verificar la firma sin problema. Lo que no puede es escribir. La capa de datos es un esquema de Amplify Gen 2 con reglas de autorización, y un route handler que corre en el cómputo SSR de Amplify no tiene ninguna identidad que el esquema autorice para escrituras de sistema: no es un usuario con sesión iniciada, y no es un recurso del backend.

Una función definida en amplify/ sí lo es. Concederle allow.resource(stripeEventProcessor) en el esquema es la forma prevista de dejar que un proceso de sistema actualice la suscripción y el registro de créditos de cualquier usuario. Stripe sigue necesitando una URL HTTPS a la que llamar, así que la función recibe una Function URL:

// amplify/backend.ts
// Safe without URL auth: the handler verifies every request's
// Stripe signature before acting.
const stripeWebhookUrl =
  backend.stripeEventProcessor.resources.lambda.addFunctionUrl({
    authType: FunctionUrlAuthType.NONE,
  })

backend.addOutput({
  custom: { stripeWebhookUrl: stripeWebhookUrl.url },
})
// amplify/functions/stripe-event-processor/resource.ts
export const stripeEventProcessor = defineFunction({
  name: 'stripe-event-processor',
  entry: './handler.ts',
  timeoutSeconds: 30,
  resourceGroupName: 'data',
  environment: {
    STRIPE_SECRET_KEY: secret('STRIPE_SECRET_KEY'),
    STRIPE_WEBHOOK_SECRET: secret('STRIPE_WEBHOOK_SECRET'),
  },
})

Ese es todo el diseño, y es correcto. El problema es lo que implica addOutput.

Una URL por rama, y solo una de ellas está a la vista

addOutput escribe la URL en amplify_outputs.json. En el sandbox, ese archivo está en tu disco, y es el que abres cuando la guía de configuración dice "copia custom.stripeWebhookUrl y pégala en el panel de Stripe." Así que el endpoint del sandbox se creó el primer día y se mantuvo al día.

Cada rama desplegada es un despliegue aparte, con su propia Lambda y su propia Function URL. Su amplify_outputs.json se genera durante el build de Amplify y nunca sale de ahí. Nada en tu máquina te muestra la URL de staging ni la de producción, así que nada te recuerda registrarlas.

Hay una segunda trampa en el mismo sitio. La URL pertenece a la Lambda, y la Lambda puede ser reemplazada. Hace poco moví el procesador al grupo de recursos data para romper un ciclo de dependencias de CloudFormation. CloudFormation reemplazó la función, y la Function URL cambió. Cualquier endpoint que apuntara a la URL antigua habría empezado a fallar en silencio. El comentario en resource.ts ahora lo dice, porque la próxima refactorización lo volverá a hacer.

Lo que había realmente en Stripe

Listar los endpoints de webhook en modo de prueba me dio dos líneas donde esperaba al menos tres:

we_…  enabled  https://<sandbox-id>.lambda-url.us-east-1.on.aws/    13 events
we_…  enabled  https://example.com/api/webhooks/stripe)             6 events

El primero era el sandbox. El segundo era un marcador de posición de la época de la ruta obsoleta. Fíjate en el paréntesis de cierre dentro de la URL: se pegó desde un enlace de Markdown y nunca funcionó. Staging y producción no tenían nada.

Las ramas desplegadas leen su clave de Stripe de un secreto compartido para toda la app. Antes de cambiar nada quería asegurarme de que esa clave era la misma que la de mi .env local, sin imprimir ninguna de las dos. Calcular el hash de ambos valores y comparar los hashes lo resolvió. La misma clave, así que los endpoints que estaba viendo eran los únicos que existían.

Encontrar la Function URL de una rama sin permisos de Lambda

Mi rol de AWS del día a día no puede llamar a lambda:GetFunctionUrlConfig, que es la forma obvia de consultar una URL. No hizo falta. El stack raíz de CloudFormation de cada rama de Amplify lleva los mismos outputs personalizados que el build escribe en amplify_outputs.json, como una cadena JSON en un output llamado customOutputs:

# The branch's root stack (the one without a parent)
aws cloudformation list-stacks \
  --stack-status-filter CREATE_COMPLETE UPDATE_COMPLETE \
  --query "StackSummaries[?starts_with(StackName, 'amplify-<appId>-staging-branch-') && !ParentId].StackName" \
  --output text

# Its custom outputs, as amplify_outputs.json would have them
aws cloudformation describe-stacks --stack-name amplify-<appId>-staging-branch-<hash> \
  --query "Stacks[0].Outputs[?OutputKey=='customOutputs'].OutputValue" --output text \
  | jq -r '.custom.stripeWebhookUrl'

La consola de Amplify también la muestra: la rama, luego Deployed backend resources, luego la función. La versión de CLI es la que puede usar un script.

De dónde sale realmente secret()

La guía de configuración que yo había escrito para este proyecto decía que los secretos de Amplify se resuelven en el momento del despliegue, así que hay que redesplegar después de cambiar uno. Es falso, y la diferencia importa aquí.

Un secret('NAME') no pone el valor en la configuración de la Lambda. Pone un marcador y una variable AMPLIFY_SSM_ENV_CONFIG que lista dos rutas de SSM por secreto. Un shim que se ejecuta antes de tu handler, una vez por arranque en frío, las lee:

1. /amplify/<appId>/<branch>/STRIPE_WEBHOOK_SECRET   ← branch value, if it exists
2. /amplify/shared/<appId>/STRIPE_WEBHOOK_SECRET     ← fallback for every branch

Tres consecuencias:

  • Un parámetro a nivel de rama sustituye al compartido. Puedes arreglar una rama sin tocar las demás.
  • No hace falta redesplegar. El siguiente arranque en frío toma el valor nuevo. Un contenedor caliente conserva el anterior hasta que Lambda lo recicla, lo que importa si rotas un secreto en un endpoint activo.
  • Un secreto de webhook compartido es incorrecto por construcción. Compartir la clave de Stripe entre ramas está bien. Pero cada endpoint tiene su propio secreto de firma, así que un único STRIPE_WEBHOOK_SECRET compartido solo puede verificar los eventos de una rama. La app tenía exactamente eso, configurado meses antes, sin coincidir con ningún endpoint que pudiera llegar a la app.

La solución: un script que sabe crear y nunca muestra el secreto

El repositorio ya tenía scripts/stripe-webhook-events.ts. Contenía la lista de eventos que maneja el procesador, encontraba el endpoint del sandbox por la URL de amplify_outputs.json y añadía los eventos que faltaran. Actualizar un endpoint conserva su secreto de firma, así que esa parte se podía ejecutar en cualquier momento.

Le añadí tres opciones:

  • --url <function-url> apunta al endpoint de una rama desplegada en lugar del del sandbox.
  • --create crea el endpoint, con todos los eventos, cuando ningún endpoint tiene esa URL.
  • --secret-ssm <name> guarda el secreto de firma del endpoint nuevo en ese parámetro de SSM.

La última existe por un detalle de Stripe: el secreto de firma solo se devuelve en la respuesta que crea el endpoint. Cualquier lectura posterior lo omite. El flujo habitual, crearlo en el panel, revelarlo, copiarlo y pegarlo en un almacén de secretos, hace pasar una credencial de producción por tu portapapeles y el historial de tu terminal. El script la escribe directamente de la respuesta de creación a SSM:

async function createEndpoint(stripe: Stripe, url: string, secretSsmPath: string | null, dryRun: boolean) {
  const ssm = secretSsmPath ? new SSMClient({}) : null
  if (ssm) {
    // Fail before creating anything when the AWS credentials are missing or expired.
    await ssm.send(new DescribeParametersCommand({ ParameterFilters: [{ Key: 'Name', Values: [secretSsmPath!] }] }))
  }
  if (dryRun) return
  const created = await stripe.webhookEndpoints.create({
    url,
    enabled_events: [...PROCESSOR_EVENTS],
    description: 'back-to-fluency-ai stripe-event-processor',
  })
  if (!ssm || !secretSsmPath) return
  if (!created.secret) throw new Error(`${created.id} was created without a signing secret in the response`)
  await ssm.send(new PutParameterCommand({ Name: secretSsmPath, Value: created.secret, Type: 'SecureString', Overwrite: true }))
}

La comprobación de credenciales antes de create es el detalle que más me gustaría que alguien copiara. Si la sesión de AWS hubiera caducado entre crear el endpoint y guardar su secreto, tendría un endpoint cuyo secreto no existe en ningún sitio. Arreglarlo implica rotar el secreto en el panel, que es justo el flujo de portapapeles que el script quería evitar.

Dos protecciones menores: el script rechaza una clave de modo live salvo que se pase --live, y --dry-run informa de lo que haría sin hacerlo. El dry run contra la URL de staging fue también como confirmé que la sesión local de AWS funcionaba antes de que pasara nada real.

Un comando por rama:

npx tsx --env-file=.env scripts/stripe-webhook-events.ts \
  --url https://<staging-id>.lambda-url.us-east-1.on.aws/ \
  --create --secret-ssm /amplify/<appId>/staging/STRIPE_WEBHOOK_SECRET

Después, el mismo script con --dry-run contra cada URL, como comprobación:

we_…  (enabled) subscribes to 13 events; missing 0: —
we_…  (enabled) subscribes to 13 events; missing 0: —

Los dos parámetros de rama aparecieron en SSM como SecureString. No se redesplegó nada, y el endpoint obsoleto de example.com se eliminó.

Mantén la lista de eventos junto al handler

Los tres eventos nuevos se registraron en ambas ramas antes de que el código de referidos llegara a ellas. Ese orden es seguro porque el procesador registra en el log cualquier tipo de evento que no reconoce y devuelve 200. Stripe ve un éxito y no reintenta. Primero registrar, después desplegar, y no hay ninguna ventana en la que exista un handler pero sus eventos no lleguen.

El orden contrario es el peligroso. Un case 'invoice.paid': nuevo en el handler pasa todos los tests unitarios, todos los tests de integración contra un evento simulado y un despliegue completo. Sigue siendo código muerto hasta que alguien registra el evento en Stripe. Mantener PROCESSOR_EVENTS como una constante exportada junto al procesador, con un test que comprueba que los eventos nuevos están en ella, convierte el registro en algo que se ve en un diff y no en algo que una persona tiene que recordar.

Una nota sobre hacerlo con un agente de IA

Hice esto con un agente de programación de IA. Hizo el descubrimiento: los outputs de CloudFormation, la comparación de claves, el listado de Stripe. También escribió y probó el script. Cuando intentó ejecutar los dos comandos de creación, su entorno se negó: escribir en un almacén de secretos era una línea que no podía cruzar por su cuenta. Ejecuté yo los dos comandos, y el agente verificó el resultado.

Ese es el reparto correcto. Un agente que puede encontrar el problema, escribir la solución y demostrar que funciona, pero que tiene que pasarle a una persona el único paso que genera una credencial, es exactamente el agente que quiero cerca de la facturación.

Reglas que generalizan

  • Cada rama es su propia integración. Si un servicio externo llama a tu backend, cuenta los despliegues y cuenta los registros. Deberían coincidir.
  • Consulta las URLs en el despliegue, no de memoria. En Amplify Gen 2, los customOutputs del stack raíz tienen lo mismo que amplify_outputs.json, para cada rama.
  • Un secreto compartido no puede verificar firmas por endpoint. Comparte claves de API si no queda otra. Los secretos de firma pertenecen a un endpoint, así que pertenecen a una rama.
  • Los secretos que existen una sola vez nunca deberían mostrarse. Escríbelos desde la respuesta de la API directamente en el almacén, y comprueba que el almacén es accesible antes de crear nada.
  • Registra los eventos antes de desplegar su handler, y mantén la lista en el código, donde una revisión pueda verla.

Referencias