appspro_sdk 0.1.0
appspro_sdk: ^0.1.0 copied to clipboard
Native Flutter widgets and a typed client for AppsPro carrier-billed subscriptions (BDApps DCB, Robi/Airtel) — phone, OTP, status and cancel.
appspro_sdk #
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();
Install #
dependencies:
appspro_sdk: ^0.1.0
Configure #
Get 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());
}
Use it #
A full screen #
final result = await AppsProSubscribeScreen.show(context);
An inline card #
AppsProSubscribeCard(
onSuccess: (result) => context.go('/premium'),
onError: (message) => debugPrint(message),
)
A gate #
AppsProSubscriptionGate(
builder: (context) => const PremiumScreen(),
fallback: (context, subscribe) => Paywall(onTap: subscribe),
)
Cancellation #
Both app stores expect a subscription to be cancellable inside the app that sold it.
await AppsProManageScreen.show(context);
Your own UI #
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: // ...
}
Theming #
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.
Routing through your own server #
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(),
);
Testing #
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_key
The 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());
Errors #
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. |
Notes #
- 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.