La facturación de suscripciones móviles a menudo parece engañosamente simple desde el exterior. El discurso de venta estándar sugiere que solo agregas un SDK de muro de pago (paywall), configuras tus productos en las respectivas tiendas de aplicaciones, escuchas los webhooks y luego actualizas tu base de datos cada vez que una compra tiene éxito. Desafortunadamente, esa narrativa es solo la mitad de la verdad. El camino feliz funciona maravillosamente hasta que inevitablemente llegas a los bordes más incómodos del sistema: entornos de sandbox, campos de identidad faltantes, condiciones de carrera, webhooks retrasados, compras restauradas, renovaciones, cancelaciones y la incómoda realidad de que la tienda de aplicaciones es dueña de la transacción mientras tu aplicación es dueña del usuario.
Me topé de frente con esta realidad mientras integraba Superwall con las suscripciones de Google Play. Aunque las compras en producción se veían bien, las pruebas en sandbox rápidamente se convirtieron en un desastre. Las compras de prueba se completaban con éxito dentro de la aplicación, pero mi backend no podía descubrir de manera confiable qué usuario debía recibir realmente el derecho premium. Lo que comenzó como una sencilla integración de muro de pago se convirtió de repente en un problema de sistemas distribuidos centrado en la identidad, la propiedad y la reconciliación. Este artículo detalla la arquitectura a la que finalmente llegué y las dolorosas lecciones que me obligaron a llegar allí.
La arquitectura en la que quería creer
La primera versión de mi flujo de facturación siguió naturalmente el modelo estándar impulsado por webhooks. El usuario tocaba un botón de actualización, Superwall presentaba el muro de pago, manejaba la compra y luego enviaba un webhook a mi backend. El backend simplemente leía el ID del usuario del webhook entrante y actualizaba a ese usuario específico.
En el escenario ideal, el payload del webhook se veía así:
{
"data": {
"originalAppUserId": "user_123",
"transactionId": "GPA.3373-4052-0812-08085",
"productId": "pro:pro-monthly"
}
}Ese payload contiene todo lo que un desarrollador backend desea. Incluye el producto, la transacción y, lo más importante, la identidad del usuario a nivel de aplicación. Dada esa información limpia, otorgar acceso es sencillo. Para compras de producción reales, este flujo funcionó excepcionalmente bien. Los usuarios compraban suscripciones, los webhooks llegaban rápidamente y la base de datos los movía al plan premium. Luego, comencé a probar adecuadamente con el sandbox de Google Play.
El sandbox rompió la suposición
Las pruebas en el sandbox de Google Play son increíblemente útiles porque los tiempos de suscripción se aceleran drásticamente. Una suscripción mensual puede renovarse cada pocos minutos, lo que hace posible probar a fondo los flujos de renovación, cancelación, vencimiento y resuscripción sin esperar un mes real. Pero mis primeras compras en el sandbox fallaron de una manera muy confusa. La compra se completaba en la aplicación, sin embargo, el usuario no se actualizaba.
Los registros del backend explicaron rápidamente por qué:
{
"data": {
"originalAppUserId": null,
"userAttributes": null,
"transactionId": "GPA.3373-4052-0812-08085",
"originalTransactionId": "GPA.3373-4052-0812-08085",
"productId": "pro:pro-monthly",
"environment": "SANDBOX"
}
}Los campos de identidad faltaban por completo. El webhook estaba declarando efectivamente: "Alguien acaba de comprar esta suscripción, y aquí está el ID de la transacción, pero no tengo absolutamente ninguna idea de quién es". Esa única omisión rompió todo el diseño porque mi backend dependía en gran medida del webhook para responder a la pregunta más crítica: ¿qué usuario es el dueño de esta compra?
El problema arquitectónico más profundo es que Google Play fundamentalmente no conoce mis IDs de usuario internos. La suscripción pertenece a una cuenta de Google, mientras que mi usuario de la aplicación pertenece a mi sistema de autenticación propietario. Superwall ciertamente puede intentar conectar esos dos dominios de identidad distintos, pero tratar ese puente como una fuente de verdad infalible en cada entorno es un error. Una vez que entendí esa desconexión, la antigua arquitectura dejó de parecer simple y comenzó a parecer increíblemente frágil.
Las soluciones fallidas
Antes de aterrizar en el modelo correcto, naturalmente intenté algunas soluciones obvias que finalmente no fueron suficientes.
Estos repetidos fracasos me obligaron a separar cuidadosamente dos conceptos distintos que había fusionado accidentalmente en mi diseño inicial.
La propiedad y el ciclo de vida son problemas diferentes
El gran avance fue darme cuenta de que la propiedad de la suscripción y el ciclo de vida de la suscripción no son el mismo problema en absoluto. La propiedad responde: ¿quién compró esta suscripción? El ciclo de vida responde: ¿cuál es el estado actual de esta suscripción? Había estado confiando en los webhooks para ambos, lo cual fue mi error fundamental.
La propiedad solo necesita establecerse una vez, y debe establecerse estrictamente mediante una acción de usuario autenticado. Los cambios en el ciclo de vida, por otro lado, ocurren repetidamente con el tiempo a través de renovaciones, cancelaciones, vencimientos, reembolsos y resuscripciones. Los webhooks son fantásticos para actualizaciones asincrónicas del ciclo de vida, pero son una fuente primaria muy pobre para la propiedad cuando pueden faltar campos de identidad críticos.
La pieza crucial de datos que finalmente hizo que esto funcionara fue el linaje de la transacción:
{
"transactionId": "GPA.3373-4052-0812-08085",
"originalTransactionId": "GPA.3373-4052-0812-08085"
}El originalTransactionId identifica firmemente la raíz de la suscripción. Permanece completamente estable en renovaciones y eventos del ciclo de vida, lo que lo convierte en la clave perfecta para vincular una suscripción de la tienda a un usuario específico de la aplicación. La segunda pieza importante del rompecabezas fue el purchaseToken, que está fácilmente disponible en el cliente después de una compra en Google Play. El backend puede usar ese token para preguntar a Google Play directamente si la compra es realmente válida.
Esta comprensión condujo a una arquitectura mucho mejor: vincular la propiedad de inmediato utilizando el contexto del cliente autenticado, verificar la compra directamente con Google Play y luego depender de los webhooks estrictamente para la reconciliación del ciclo de vida en el futuro.
La arquitectura que funcionó
En el diseño final, el webhook ya no es la autoridad que decide quién obtiene acceso premium. En cambio, sirve como un mensajero en segundo plano que mantiene actualizado el estado general de la suscripción.
El flujo de compra inmediato ahora comienza en el cliente. Cuando Superwall informa que se ha completado una transacción, la aplicación captura el linaje de la transacción y el token de compra. Luego envía esos valores específicos a mi backend bajo la sesión de autenticación normal y segura del usuario.
POST /v1/billing/superwall-binding
Authorization: Bearer <user_token>
Content-Type: application/json
{
"store": "PLAY_STORE",
"originalTransactionId": "GPA.3373-4052-0812-08085",
"transactionId": "GPA.3373-4052-0812-08085",
"purchaseToken": "AOP..."
}De manera crucial, el backend no confía en ningún ID de usuario oculto dentro del cuerpo. Se basa completamente en el encabezado Authorization para identificar al usuario. Luego, crea un vínculo que declara definitivamente: este usuario autenticado es dueño de este ID de transacción original. Ese vínculo se convierte en el registro de propiedad permanente.
A continuación, el backend verifica inmediatamente la compra directamente con Google Play:
GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{purchaseToken}Si Google responde que el token de compra es válido y la suscripción está activa, el backend otorga el derecho en ese mismo momento. El usuario obtiene acceso premium instantáneamente después de completar la compra, eliminando por completo cualquier dependencia en una espera de webhook.
Más tarde, cuando un webhook de Superwall eventualmente llega, puede faltarle con seguridad campos de identidad y seguir siendo útil:
{
"data": {
"originalAppUserId": null,
"originalTransactionId": "GPA.3373-4052-0812-08085",
"name": "renewal",
"expirationAt": 1775832567843
}
}El backend simplemente usa el originalTransactionId para buscar la vinculación, y luego actualiza con seguridad el registro de suscripción existente. El webhook ya no necesita saber quién es el usuario, porque el sistema ya lo sabe.
Por qué esto maneja mejor los casos extremos
Este modelo desacoplado hace que todos los feos casos extremos sean significativamente más fáciles de razonar.
Si un webhook llega antes de la vinculación del cliente, el backend simplemente lo almacena como pendiente. Cuando el cliente autenticado envía posteriormente la vinculación, el backend puede reproducir con seguridad ese evento de ciclo de vida pendiente. Si un usuario se vuelve a suscribir directamente desde la aplicación Play Store en lugar de dentro de mi aplicación, no se activará ningún evento inmediato del cliente. Sin embargo, el webhook aún puede llegar y permanecer en estado pendiente hasta que el usuario abra la aplicación nuevamente. Una vez que el SDK detecta la compra restaurada, el cliente envía la vinculación y el backend se pone al día sin problemas.
Lo más importante es que, si el sandbox de Google Play omite los campos de identidad, nada se rompe. La verificación directa se basa en el token de compra y la reconciliación del webhook se basa en el ID de la transacción original. Ningún paso crítico depende de que originalAppUserId esté realmente presente. Esta es la profunda diferencia entre un sistema de facturation que simplemente espera que todas las integraciones preserven la identidad, y uno que hace cumplir su propio modelo de propiedad autoritativo.
El modelo mental
La tienda es dueña de la transacción. Tu aplicación es dueña del usuario. Tu backend debe poseer firmemente el vínculo entre ellos. Una vez que realmente aceptas esa dinámica, toda la arquitectura se vuelve mucho más clara.
| Preocupación | Fuente de verdad |
|---|---|
| Identidad del usuario | Tu sistema de autenticación |
| Validez de la compra | Google Play o App Store |
| Propiedad de la suscripción | Tu tabla de vinculación |
| Renovaciones y cancelaciones | Eventos de la tienda a través de webhooks, reconciliados por vinculaciones |
Al cliente se le permite perfectamente llevar datos de correlación, como el purchaseToken y el originalTransactionId. Sin embargo, fundamentalmente no se le permite otorgarse acceso a sí mismo. El backend siempre debe verificar la transacción con la tienda antes de crear derechos duraderos.
La lección
La facturación móvil confiable requiere tratar fundamentalmente los webhooks como mensajes asincrónicos del ciclo de vida, en lugar de tratarlos como registros de identidad perfectos. Los webhooks pueden retrasarse, pueden llegar completamente fuera de orden y fácilmente pueden carecer de campos de usuario críticos. Los entornos de sandbox pueden comportarse de manera muy diferente a los de producción. Ninguno de esos factores debería jamás decidir si un comprador legítimo realmente obtiene acceso a las funciones por las que pagó.
El patrón robusto y resistente consiste en establecer firmemente la propiedad a través de una solicitud de cliente autenticado, verificar la compra directamente con la tienda y usar los webhooks únicamente para mantener el ciclo de vida actualizado a lo largo del tiempo. Ese único cambio arquitectónico estabilizó por completo mi sistema. Dejé de pedirle al webhook que me dijera quién era el dueño de la suscripción, hice explícita la propiedad, la verifiqué en la fuente y dejé que los webhooks hicieran el trabajo de sincronización en segundo plano para el que están realmente construidos.
Leer más
Cómo funcionan las apps móviles
Las tres formas de construir una app móvil, qué ejecuta realmente cada una por debajo, y el patrón de arquitectura compartido detrás de React Native, Flutter y Tauri mobile.
Agrupar claves de API con Bifrost
Una guía práctica para configurar la agrupación de claves de API en Bifrost y cómo escalarla de forma segura entre múltiples nodos externalizando tus rate limits.
OAuth vs OIDC: La Diferencia Explicada por Fin
Una explicación práctica de OAuth, OIDC, access tokens e ID tokens sin la habitual confusión sobre autenticación.
