Cómo añadir compras integradas a una app de Flutter
Añade compras integradas a una app Flutter con el plugin in_app_purchase. Configura las tiendas de Apple y Google, verifica transacciones, restaura compras y prueba sin riesgos.
Una app de Flutter que vende bienes digitales o suscripciones en iOS o Android, por lo general, debe cobrar a través del sistema de compras integradas de Apple o de Google Play Billing. Stripe y los checkouts personalizados no están permitidos para este fin, y ambas tiendas se quedan con una comisión por cada venta.
Si tu app ya acepta pagos con Stripe en la web, es posible que esperes reutilizar ese checkout en la versión móvil. En el caso de los bienes digitales, ese plan suele terminar con un rechazo por parte de App Review.
La comisión estándar de Apple es del 30 %. A través del App Store Small Business Program, los desarrolladores que obtuvieron hasta 1 millón de USD en ingresos netos el año anterior pagan un 15 %, tanto en apps de pago como en compras integradas. Google no tiene una tarifa única, y su página de tarifas de servicio indica que el 99 % de los desarrolladores que pagan una tarifa cumplen los requisitos para una del 15 % o inferior. Stripe sigue siendo la herramienta adecuada para bienes físicos y para servicios que se consumen fuera de la app. Las normas se recogen en las directrices de revisión de la App Store sobre compras integradas de Apple y en la política de pagos de Play de Google. En la UE, la Ley de Mercados Digitales (DMA) obliga a las tiendas a permitir opciones de pago alternativas, y Apple describe sus mecanismos para la UE en su página para desarrolladores sobre la DMA. En el storefront de Estados Unidos, las directrices de Apple ahora también permiten que las apps incluyan botones y enlaces a opciones de compra externas. Las normas sobre enlaces a pagos externos varían según la región y cambian constantemente, así que consulta las directrices vigentes de ambas tiendas antes de diseñar tu flujo.
Este artículo cubre de principio a fin el plugin oficial in_app_purchase: tipos de producto, configuración de las tiendas, una clase de servicio funcional, los fallos que hacen perder compras y las pruebas. Si eres nuevo en Flutter, empieza por la guía de Flutter para nuevos desarrolladores.
Puntos clave
- Los bienes digitales que se venden dentro de apps de iOS y Android, por lo general, deben pasar por los sistemas de facturación de Apple y Google. Stripe cubre los bienes físicos y los servicios que se consumen fuera de la app.
buyConsumableybuyNonConsumabledevuelven unFuture<bool>que solo indica que la solicitud se ha iniciado. El resultado real llega comoPurchaseDetailsenpurchaseStream.- Si no se llama a
completePurchaseen una compra cuyopendingCompletePurchasees true, Google Play la reembolsa al cabo de tres días y la App Store deja la transacción sin finalizar. - Las directrices de revisión de Apple exigen un mecanismo de restauración para las compras restaurables.
- Desbloquea el contenido solo después de que tu backend haya validado
serverVerificationDatacon la App Store Server API o la Google Play Developer API.
Consumibles, no consumibles y suscripciones
El plugin in_app_purchase de Flutter admite tres tipos de producto: consumibles, no consumibles y suscripciones. Los consumibles usan buyConsumable. Los no consumibles y las suscripciones usan buyNonConsumable. El tipo también determina qué debe recordar tu app.
| Tipo | Método de compra | Ejemplo | Qué debe registrar tu app | ¿Restaurable? |
|---|---|---|---|---|
| Consumible | buyConsumable | 100 monedas | Saldo, almacenado en tu backend | No |
| No consumible | buyNonConsumable | Desbloqueo Premium | Indicador de derecho de acceso | Sí |
| Suscripción | buyNonConsumable | Pro mensual | Fecha de vencimiento y estado de renovación, a partir de la verificación en el servidor | Sí |
Los consumibles son el único tipo que la tienda no recuerda por ti. Una vez consumido un paquete de monedas, tu propio backend tiene el único registro del saldo. El derecho de acceso de un no consumible siempre se puede reconstruir con una restauración. El estado de una suscripción cambia con el tiempo debido a renovaciones, caducidades y reembolsos, así que léelo a partir de datos verificados en el servidor, no de un indicador local almacenado en caché.
La configuración de las tiendas es la mayor parte del trabajo
La mayor parte del esfuerzo de añadir compras integradas a una app de Flutter se concentra en App Store Connect y Play Console. La parte de Dart es breve. Hasta que ambas tiendas estén configuradas, queryProductDetails no devolverá nada, por muy correcto que sea tu código.
Apple (App Store Connect):
- Firma el acuerdo de apps de pago (Paid Apps Agreement) y completa los datos bancarios y fiscales.
- Crea cada compra integrada con un ID de producto como
premium_unlock. Crea las suscripciones dentro de un grupo de suscripciones. - Completa los metadatos para que cada producto alcance el estado «Listo para enviar» (Ready to Submit).
Google (Play Console):
- Sube una compilación a un canal de pruebas. El canal interno sirve.
- Crea los productos. Usa los mismos ID que en Apple o mapéalos en el código.
- Actívalos. Google indica que los productos deben estar publicados antes de que alguien pueda probarlos.
Si queryProductDetails incluye tus ID en notFoundIDs, la causa casi siempre es la configuración de la tienda, no Dart. Las causas habituales son un ID que no coincide, un producto inactivo, un acuerdo de apps de pago sin firmar o que no se haya subido ninguna compilación a un canal de pruebas de Play.
Código de compras integradas en Flutter: stream, compra y finalización
El flujo de compras integradas en Flutter consta de cuatro pasos. Suscríbete a purchaseStream al inicio, comprueba isAvailable, carga los productos con queryProductDetails y, a continuación, llama a un método de compra. Gestiona cada resultado en el listener del stream.
Añade la dependencia:
dependencies:
in_app_purchase: ^3.3.1
El paquete in_app_purchase es compatible con Android SDK 24+, iOS 13+ y macOS 10.15+. Las implementaciones de StoreKit y Play Billing son paquetes federados respaldados (endorsed) que se incluyen automáticamente, por lo que solo necesitas añadirlos tú mismo si usas sus clases específicas de plataforma. En las plataformas de Apple, StoreKit 2 es la opción predeterminada en los dispositivos que lo admiten.
buyConsumable y buyNonConsumable no devuelven una compra. Un error habitual tiene este aspecto:
// Wrong: the Future resolves to a bool, not a purchase
final result = await iap.buyNonConsumable(purchaseParam: param);
if (result) unlockPremium(); // true only means the sheet opened
La versión correcta trata el bool como «solicitud iniciada» y realiza todas las concesiones en el listener del stream:
final started = await iap.buyNonConsumable(purchaseParam: param);
if (!started) showError('Could not start purchase');
// Unlocking happens in the purchaseStream handler
Este es el servicio completo, con la suscripción realizada en main() antes de runApp. El README del plugin recomienda suscribirse en el initState de tu app. Hacerlo en main() adelanta ese paso, de modo que exista un listener antes de que el plugin pueda emitir cualquier actualización.
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
class PurchaseService {
PurchaseService._();
static final instance = PurchaseService._();
static const coins100 = 'coins_100';
static const premium = 'premium_unlock';
final InAppPurchase _iap = InAppPurchase.instance;
StreamSubscription<List<PurchaseDetails>>? _sub;
void start() {
_sub ??= _iap.purchaseStream.listen(
_onPurchases,
onDone: () => _sub?.cancel(),
onError: (Object e) {/* log */},
);
}
Future<List<ProductDetails>> loadProducts() async {
if (!await _iap.isAvailable()) return [];
final res = await _iap.queryProductDetails({coins100, premium});
if (res.notFoundIDs.isNotEmpty) {/* store setup problem */}
return res.productDetails;
}
Future<bool> buy(ProductDetails p) {
final param = PurchaseParam(productDetails: p);
return p.id == coins100
? _iap.buyConsumable(purchaseParam: param)
: _iap.buyNonConsumable(purchaseParam: param);
}
Future<void> restore() => _iap.restorePurchases();
Future<void> _onPurchases(List<PurchaseDetails> purchases) async {
for (final p in purchases) {
var finish = true;
switch (p.status) {
case PurchaseStatus.pending:
// show "waiting for approval"
break;
case PurchaseStatus.purchased:
case PurchaseStatus.restored:
final valid = await _verifyOnServer(p); // null = unreachable
if (valid == null) {
finish = false; // redelivered next launch
} else if (valid) {
await _deliver(p);
}
case PurchaseStatus.error:
// show p.error?.message
break;
case PurchaseStatus.canceled:
// no error UI
break;
}
if (finish && p.pendingCompletePurchase) {
await _iap.completePurchase(p);
}
}
}
Future<bool?> _verifyOnServer(PurchaseDetails p) async {
// POST p.verificationData.serverVerificationData,
// p.productID and platform to your backend
return true;
}
Future<void> _deliver(PurchaseDetails p) async {/* credit coins / set flag */}
}
void main() {
WidgetsFlutterBinding.ensureInitialized();
PurchaseService.instance.start();
runApp(const MyApp());
}
_verifyOnServer es un marcador de posición para la llamada a tu backend. Devuelve null cuando no se puede contactar con el servidor, de modo que la compra queda sin finalizar y vuelve a entregarse en el siguiente inicio. Esto solo es seguro si se resuelve dentro del plazo de tres días de Android.
| Estado | Qué hace la app | ¿Llamar a completePurchase? |
|---|---|---|
pending | Mostrar un estado de espera (por ejemplo, «Solicitar compra» [Ask to Buy] o un método de pago diferido) | No, espera al estado final |
purchased | Verificar en el servidor y, después, entregar | Sí, si pendingCompletePurchase |
restored | Verificar y volver a conceder el derecho de acceso | Sí, si pendingCompletePurchase |
error | Mostrar error.message | Sí, si pendingCompletePurchase |
canceled | Volver discretamente al paywall | Sí, si pendingCompletePurchase |
Qué falla en las compras integradas
Hay cuatro errores habituales que hacen perder compras o provocan el rechazo de apps: la falta de un botón de restauración, una suscripción al stream que empieza demasiado tarde, no llamar nunca a completePurchase o confiar en el cliente.
Falta el botón de restaurar. La directriz 3.1.1 de Apple indica que las apps deben ofrecer una forma de restaurar cualquier compra integrada restaurable. Las compras restauradas llegan a purchaseStream con PurchaseStatus.restored, por lo que el botón solo tiene que llamar a restorePurchases(). Tu handler existente concede el acceso. Colócalo en el paywall y en los ajustes:
TextButton(
onPressed: PurchaseService.instance.restore,
child: const Text('Restore Purchases'),
)
Si una notificación push o un enlace de marketing abre el paywall, gestiona la navegación con Deep Linking en Flutter para que el botón de restaurar esté siempre a un toque de distancia.
Escuchar demasiado tarde. Una compra que se completa mientras tu app está cerrada, como una solicitud de «Solicitar compra» aprobada o un pago lento, se entrega en el stream la próxima vez que se inicia la app. Si el listener se adjunta en una pantalla a la que el usuario nunca llega, esa actualización queda sin gestionar. El usuario no recibe lo que ha pagado y, en Android, una compra que nunca se finaliza se reembolsa al cabo de tres días.
No finalizar nunca. Si el pendingCompletePurchase de una compra es true y nunca se llama a completePurchase, Google Play la reembolsa al cabo de tres días y la App Store deja la transacción sin finalizar.
Confiar en el cliente. No desbloquees contenido basándote en el estado que recibe tu app. Envía verificationData.serverVerificationData a tu backend y valídalo allí: con la App Store Server API en las plataformas de Apple o con la Google Play Developer API en Android. Con StoreKit 2, serverVerificationData es una transacción JWS firmada. Tu servidor debe verificar su firma, consultar la transacción con la App Store Server API o hacer ambas cosas. La solicitud de verificación es una llamada normal al backend. Si tu backend usa GraphQL, Consumir APIs GraphQL con Flutter muestra cómo conectarlo.
El diseño del paywall y los SDK de suscripciones gestionadas, como RevenueCat, son decisiones independientes que se sitúan por encima de este flujo.
¿Cómo se prueban las compras sin dinero real?
Apple y Google ofrecen cada uno un entorno de pruebas para las compras integradas. Apple ofrece cuentas sandbox y un archivo de configuración de StoreKit en Xcode. Google ofrece testers de licencias en un canal de pruebas de Play.
- Testers sandbox de Apple: crea cuentas sandbox en App Store Connect e inicia sesión con una de ellas en un dispositivo. Las compras en sandbox pasan por tus productos reales de App Store Connect sin cobrar a nadie.
- Archivo de configuración de StoreKit: con un archivo de configuración de StoreKit en Xcode, puedes definir productos localmente y probar compras en el simulador de iOS antes de que App Store Connect esté completamente configurado.
- Testers de licencias de Play: en Play Console, abre Configuración > Prueba de licencias (Settings > License testing) y añade cuentas de Google. Las compras de prueba que realizan los testers de licencias no se cargan en sus cuentas. La guía de pruebas de facturación de Google advierte que los testers de un canal de pruebas que no son testers de licencias pagan con dinero real.
Prueba una instalación limpia y la restauración en ambas plataformas antes de enviar la app.
Publícala
Para un flujo de compras integradas correcto en Flutter: configura ambas tiendas, suscríbete a purchaseStream antes de que se ejecute cualquier otra cosa, verifica cada compra en tu servidor y finaliza todas las compras que tengan pendingCompletePurchase activado. A continuación, crea un producto en cada consola, pruébalo con una cuenta sandbox y con un tester de licencias, y confirma que aparece en productDetails y no en notFoundIDs.
Preguntas frecuentes
¿Cómo hago un upgrade o downgrade de una suscripción en Android con in_app_purchase?
Llama a buyNonConsumable con un GooglePlayPurchaseParam que incluya un ChangeSubscriptionParam. Pásale el GooglePlayPurchaseDetails anterior del usuario y, si lo deseas, un ReplacementMode como ReplacementMode.withTimeProration. Estas clases proceden de in_app_purchase_android, así que importa ese paquete cuando las necesites. En iOS puedes omitir este paso. Las suscripciones de un mismo grupo de suscripciones ya se sustituyen entre sí, por lo que la App Store gestiona el cambio.
¿Funciona el plugin in_app_purchase en Flutter web?
No. El plugin necesita una tienda detrás y solo es compatible con la App Store (en iOS y macOS) y Google Play (en Android). Una compilación de Flutter web no tiene ninguna tienda detrás, por lo que las ventas en la web requieren un procesador de pagos web como Stripe. La restricción de plataforma sobre Stripe se aplica a los bienes digitales vendidos dentro de apps de iOS y Android, no a las compras realizadas en la web.
¿Cuál es la diferencia entre in_app_purchase y purchases_flutter de RevenueCat?
in_app_purchase solo gestiona la conexión con la tienda. La verificación en el servidor, el almacenamiento de los derechos de acceso y el seguimiento del estado de las suscripciones quedan a cargo de tu propio backend. purchases_flutter de RevenueCat envuelve StoreKit y Google Play Billing junto con el backend alojado de RevenueCat, que añade validación de recibos en el servidor, seguimiento del estado de las suscripciones y webhooks. Ambos paquetes siguen pasando por la facturación de Apple y Google, por lo que las tiendas cobran la misma comisión en cualquier caso.
¿Qué ocurre con los suscriptores existentes cuando subo el precio de una suscripción?
Depende de la tienda, la región y la magnitud de la subida. Algunas subidas requieren el consentimiento del suscriptor y, si no lo da, la suscripción deja de renovarse. Ambas tiendas también permiten algunas subidas con solo un aviso previo. En iOS, StoreKit muestra de forma predeterminada una hoja de consentimiento de precio dentro de tu app, y el README de in_app_purchase explica cómo posponerla hasta un momento más oportuno. En Android, Google Play informa a los suscriptores del cambio por correo electrónico y notificación, por lo que no necesitas código Dart para activarlo.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k