Native Flutter widgets and a typed client for AppsPro carrier-billed subscriptions — BDApps direct carrier billing on Robi and Airtel in Bangladesh.
Your user enters their number, gets an SMS code, and is subscribed. No WebView.
final result = await AppsProSubscribeScreen.show(context);
if (result?.isSubscribed ?? false) unlockPremium();dependencies:
appspro_sdk: ^0.1.0Get your publishable key from the API & SDK tab of your app on appspro.dev. It is safe to ship in a binary. Your secret key is not — it authorises server-to-server calls and doubles as your webhook signing key, so it must never appear in an app.
void main() {
AppsProClient.configure(publishableKey: 'pk_...');
runApp(const MyApp());
}final result = await AppsProSubscribeScreen.show(context);AppsProSubscribeCard(
onSuccess: (result) => context.go('/premium'),
onError: (message) => debugPrint(message),
)AppsProSubscriptionGate(
builder: (context) => const PremiumScreen(),
fallback: (context, subscribe) => Paywall(onTap: subscribe),
)Both app stores expect a subscription to be cancellable inside the app that sold it.
await AppsProManageScreen.show(context);SubscribeController is the state machine behind the widgets. Drive it
directly and build whatever screens you like.
final controller = SubscribeController()..load();
controller.addListener(() => setState(() {}));
await controller.submitPhone('01712345678');
await controller.submitOtp('123456');
switch (controller.step) {
case SubscribeStep.otp: // show the code field
case SubscribeStep.success: // controller.result
default: // ...
}The widgets are Material 3 and read everything from Theme.of(context), so
they look like your app rather than like a payment vendor. AppsProOptions
covers the rest:
AppsProSubscribeCard(
options: const AppsProOptions(
subscribeLabel: 'Start my plan',
showBranding: false,
),
)Two things are always shown, because BDApps requires them before a user agrees to a recurring carrier charge: the price disclosure line and the app header naming who is charging them.
By default the package talks to AppsPro directly using the publishable key. If you already have a backend and a signed-in user, route calls through it instead: your server holds the secret key, so it can tie a subscription to your own user id and authorise and log every call.
AppsProClient.configure(
publishableKey: 'pk_...',
backend: AppsProProxyBackend(
baseUrl: 'https://api.mystore.com',
headers: () async => {'Authorization': 'Bearer ${await session.token()}'},
),
);Your server implements five routes. The paths are a convention — rename them
with AppsProProxyPaths.
| Route | Body | Returns |
|---|---|---|
GET /appspro/app-info |
— | the /api/v1/sdk/app-info payload |
POST /appspro/otp/request |
{phone, external_user_id?} |
{reference_no, status_code, ...} |
POST /appspro/otp/verify |
{reference_no, otp, external_user_id?} |
{subscriber_id, subscription_status, ...} |
GET /appspro/subscription |
— | {valid, status, reason?} |
POST /appspro/unsubscribe |
— | any 2xx |
A reference proxy in Express:
const APPSPRO = 'https://api.appspro.dev';
const auth = { Authorization: `Bearer ${process.env.APPSPRO_SECRET_KEY}` };
app.get('/appspro/app-info', async (req, res) => {
const r = await fetch(`${APPSPRO}/api/v1/sdk/app-info?publishable_key=${process.env.APPSPRO_PUBLISHABLE_KEY}`);
res.status(r.status).json(await r.json());
});
app.post('/appspro/otp/request', requireLogin, async (req, res) => {
const r = await fetch(`${APPSPRO}/api/v1/sdk/otp/request`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: req.body.phone, external_user_id: req.user.id }),
});
res.status(r.status).json(await r.json());
});
// otp/verify mirrors the above against /api/v1/sdk/otp/verify.
app.get('/appspro/subscription', requireLogin, async (req, res) => {
const r = await fetch(`${APPSPRO}/api/v1/sdk/verify/${req.user.subscriberId}`, { headers: auth });
const body = await r.json();
res.json({ valid: body.valid, status: body.subscriber?.status, reason: body.reason });
});
app.post('/appspro/unsubscribe', requireLogin, async (req, res) => {
const r = await fetch(`${APPSPRO}/api/v1/sdk/unsubscribe`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ phone: req.user.phone }),
});
res.status(r.status).json(await r.json());
});And in FastAPI:
APPSPRO = "https://api.appspro.dev"
AUTH = {"Authorization": f"Bearer {os.environ['APPSPRO_SECRET_KEY']}"}
@router.post("/appspro/otp/request")
async def request_otp(body: PhoneIn, user: User = Depends(current_user)):
async with httpx.AsyncClient() as c:
r = await c.post(
f"{APPSPRO}/api/v1/sdk/otp/request",
headers=AUTH,
json={"phone": body.phone, "external_user_id": user.id},
)
return JSONResponse(r.json(), status_code=r.status_code)You can also override just one call and leave the rest direct:
AppsProClient.configure(
publishableKey: 'pk_...',
statusResolver: () async => myBackend.subscriptionStatus(),
);Sandbox apps talk to a mock carrier: no SMS, no charges, and the OTP is always
000000. Create one on the API & SDK tab, then:
flutter run --dart-define=APPSPRO_PUBLISHABLE_KEY=pk_sandbox_keyThe sandbox app and its keys are deleted automatically when your app is published, so make sure your release build uses the production key.
In your own tests, implement AppsProBackend — no HTTP needed:
class FakeBackend implements AppsProBackend {
@override
Future<SubscriptionStatus> status() async =>
const SubscriptionStatus(isSubscribed: true, state: SubscriptionState.active);
// ...
}
AppsProClient.configure(publishableKey: 'pk_test', backend: FakeBackend());Everything throws an AppsProException subclass, so you can tell retry from
re-auth without matching on strings:
| Exception | Meaning |
|---|---|
AppsProNetworkException |
Never reached the server. Retry. |
AppsProInvalidPhoneException |
Not a valid BD mobile number. |
AppsProInvalidOtpException |
Wrong or expired code. |
AppsProRateLimitException |
10 OTPs/hour per number exhausted. |
AppsProUnauthorizedException |
No valid session; subscribe again. |
AppsProNotConfiguredException |
The app has no BDApps credentials yet. |
AppsProUnknownAppException |
The publishable key matches no app. |
- Access is a server decision. The gate reflects it, but verify entitlement on your own server before handing out anything valuable — a determined user controls their device.
subscriber.createdfires when the carrier confirms, not at OTP verify, so a webhook may lag a successful subscribe by seconds or minutes. Status reportspendingin that window and the SDK treats it as subscribed.- Bangladesh only. BDApps bills Robi and Airtel numbers;
AppInfo.supportedCountriesreports what the app can actually charge.