AppsPro Developer Guide
AppsPro is a subscription management platform built on top of the Dialog Axiata BDApps telecom API. This guide covers what you need to integrate your app with AppsPro: configuring the BDApps portal, verifying subscriptions from your backend, and receiving signed webhook events.
How it works
https://api.appspro.dev/api/v1. SDK routes use Authorization: Bearer <secret_key>. The Client ID, Client Secret, Signing Key, and Public Key are all visible in your app's API tab in the dashboard.BDApps Integration
BDApps delivers SMS, USSD, and subscription notifications by POSTing to URLs you configure in the BDApps developer portal. Paste the three flat URLs below — they are identical for every AppsPro customer. AppsPro routes each delivery to the correct app using the BDApps applicationId in the payload.
Portal URLs
The same values appear in the BDApps Portal Configuration card on your app's Subscription tab.
https://api.appspro.dev/bdapps/smsSMS MO callback. Paste into the BDApps portal as the SMS notification URL.No Auth
https://api.appspro.dev/bdapps/ussdUSSD MO callback. Paste into the BDApps portal as the USSD URL.No Auth
https://api.appspro.dev/bdapps/notifySubscription notification callback (subscribe / unsubscribe / renewal events).No Auth
https://api.appspro.dev/bdapps/reportSMS delivery report callback.No Auth
How routing works
You do not pass an app_id in the URL. Every BDApps payload includes an applicationId (your bdapps_app_id), which AppsPro uses to look up the right app. The same URL serves every customer.
You can also read the configured URLs from the platform endpoint:
/platform/bdapps-portal-configReturns the SMS / USSD / Notify URLs and whitelisted IPs. Useful if you want to validate the dashboard values programmatically.No Auth
{
"sms_url": "https://api.appspro.dev/bdapps/sms",
"ussd_url": "https://api.appspro.dev/bdapps/ussd",
"notify_url": "https://api.appspro.dev/bdapps/notify",
"whitelisted_ips": ["217.15.160.79"]
}Whitelisted IPs
AppsPro's outbound traffic to BDApps originates from 217.15.160.79. Add this address to the IP whitelist in your BDApps portal so outbound SMS, charging, and OTP requests are not blocked.
Quickstart
1. Sign up & create an app
Create an account at appspro.dev and add a new app from the dashboard. In the app's settings, enter your bdapps_app_id (applicationId) and bdapps_password from your BDApps developer portal (developer.bdapps.com). The password is encrypted at rest and never returned over the API.
2. Copy your API credentials
Open your app's API tab. You'll find:
Base URIhttps://api.appspro.dev/api/v1 — the root for SDK calls
publishable_keyClient-side safe — used in WebSDK init code and /sdk/app-info (e.g. pk_…)
secret_keyServer-side only — Bearer token for SDK calls AND HMAC key for verifying outbound webhook signatures. Shown once on regenerate.
url_slugShort share handle for the hosted checkout URL: appspro.dev/s/<slug>. Stable across credential rotations.
3. Configure your BDApps portal
Open your app's Subscription tab to view the BDApps Portal Configuration card. Paste the SMS URL, USSD URL, and Subscription Notification URL into the matching fields in your BDApps developer portal, and whitelist AppsPro's outbound IP. See BDApps Integration for the full list of URLs.
4. Make your first API call
List your current subscribers with your secret_key:
curl -H "Authorization: Bearer YOUR_SECRET_KEY" \
"https://api.appspro.dev/api/v1/sdk/subscribers?limit=20"SDK Guide
Authentication
Every authenticated SDK call uses an API key in a Authorization: Bearer header. The key is your app's secret_key (starts with sk_) — copy it once from the API tab after regenerating.
Authorization: Bearer sk_<your-secret>Keep credentials secure
Never expose your secret_key in client-side code. It's the Bearer token for SDK calls AND the HMAC key for webhook signature verification — both server-side concerns.
Verify Subscription
Check whether a subscriber has an active subscription. The {subscriber_id} path segment is the BDApps subscriber ID returned by /sdk/subscribers (e.g. tel:8801XXXXXXXXX), not the internal AppsPro UUID.
/api/v1/sdk/verify/{subscriber_id}Returns subscription validity, subscriber details, and reason if invalid.
{
"valid": true,
"subscriber": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"bdapps_subscriber_id": "tel:8801712345678",
"phone_masked": "01712***678",
"status": "active",
"subscription_type": "bdapps",
"frequency": "daily",
"subscribed_at": "2026-05-11T10:30:00Z",
"cancelled_at": null,
"created_at": "2026-05-11T10:30:00Z"
},
"reason": null
}List Subscribers
Paginated list of all subscribers for your app. Filter by status.
/api/v1/sdk/subscribers?page=1&limit=20&status=activeReturns paginated subscriber list with total count.
{
"subscribers": [
{
"id": "...",
"bdapps_subscriber_id": "tel:8801712345678",
"phone_masked": "01712***678",
"status": "active",
"subscription_type": "bdapps",
"frequency": "daily",
"subscribed_at": "2026-05-11T10:30:00Z",
"cancelled_at": null,
"created_at": "2026-05-11T10:30:00Z"
}
],
"total": 42,
"page": 1,
"limit": 20
}App Info (Public)
Retrieve public metadata for your app. No authentication — only the publishable_key.
/api/v1/sdk/app-info?publishable_key=pk_…Public app metadata. Used by the WebSDK to render the widget header.No Auth
{
"name": "Deendar",
"description": "Daily Islamic content",
"category": "islamic",
"pricing_model": "subscription",
"icon_url": "https://api.appspro.dev/uploads/icons/abc.png",
"publishable_key": "pk_..."
}Subscription Status (by phone)
Query upstream BDApps for a phone number's subscription state. Unlike /sdk/verify (which reads local DB and is keyed by subscriber_id), this hits BDApps live and accepts a raw 01XXXXXXXXX phone. Useful when you don't have the subscriber id yet, or want truth even before our webhook lands.
/api/v1/sdk/statusLive BDApps subscription status for a phone number.
// Request
{ "phone": "01712345678" }
// Response — SubscriptionStatusOut
{
"subscription_status": "REGISTERED", // or UNREGISTERED, etc.
"status_code": "S1000",
"status_detail": "Success",
"raw": { /* full BDApps response */ }
}Request OTP
Send a one-time password to the phone number. BDApps delivers the OTP as a real SMS. The returned reference_no must be passed back to /sdk/otp/verify. Rate-limited to 10 requests / hour / phone / app.
/api/v1/sdk/otp/requestSend an OTP SMS to a phone number.
// Request
{ "phone": "01712345678" }
// Response — OTPRequestOut
{
"reference_no": "bdapps_ref_abc123",
"status_code": "S1000",
"status_detail": "Success",
"raw": { /* full BDApps response */ }
}Verify OTP
Verify the OTP entered by the user. On success the subscriber row is created or updated locally and the response tells you immediately. The subscriber.created (or subscriber.reactivated) webhook is fired separately, when BDApps posts its confirming callback to /bdapps/notify — usually seconds later, but don't block on it. Treat the verify response as the signal that the user subscribed.
/api/v1/sdk/otp/verifyVerify OTP and register the subscription.
// Request
{ "reference_no": "bdapps_ref_abc123", "otp": "1234" }
// Response — OTPVerifyOut
{
"subscription_status": "REGISTERED",
"subscriber_id": "tel:8801712345678",
"local_subscriber_id": "550e8400-...",
"status_code": "S1000",
"status_detail": "Success",
"raw": { /* full BDApps response */ }
}Subscribe (no OTP)
Directly subscribe a phone number, bypassing OTP. Use for trusted server-to-server flows (e.g. you've already verified the user via your own auth). For end-user signups, prefer the OTP flow above.
/api/v1/sdk/subscribeDirectly subscribe a phone number.
// Request
{ "phone": "01712345678" }
// Response — BDAppsResponse
{
"status_code": "S1000",
"status_detail": "Success",
"raw": { /* full BDApps response */ }
}Unsubscribe
Unsubscribe a phone number. On success the local subscriber moves to CANCELLED and a subscriber.cancelled webhook fires. Counted as success when BDApps returns eitherstatusCode: S1000 orsubscriptionStatus: UNREGISTERED.
/api/v1/sdk/unsubscribeUnsubscribe a phone number.
// Request
{ "phone": "01712345678" }
// Response — BDAppsResponse
{
"status_code": "S1000",
"status_detail": "Success",
"raw": { /* full BDApps response, may include "subscriptionStatus": "UNREGISTERED" */ }
}Generate Token
Short-lived (5-minute) JWT used by the WebSDK's iframe flow. The token is scoped to the requesting origin (Origin/Referer header).
/api/v1/sdk/generate-tokenIssue an embed token for the WebSDK.No Auth
// Request
{ "publicKey": "..." }
// Response
{ "token": "eyJhbGciOi...", "expires_in": 300 }Checkout Flow
Hosted Checkout Page
AppsPro provides a hosted checkout page for end-user subscriptions. Share or embed the checkout URL in your app. Users subscribe via OTP — no integration work needed on your end.
https://appspro.dev/s/{url_slug}Flow
- User visits
/s/{url_slug}— sees your app info - User enters their Bangladeshi mobile number
- OTP is sent via BDApps SMS
- User verifies OTP on the checkout page
- Subscription is created — user is redirected to the configured
checkout_redirect_url(set in the app's settings tab in the dashboard)
Checkout Endpoints
These endpoints power the hosted checkout page. No authentication required.
/s/{url_slug}/infoPublic app info for the checkout UI.No Auth
// CheckoutAppInfo
{
"name": "Deendar",
"description": "Daily Islamic content",
"icon_url": "https://api.appspro.dev/uploads/icons/abc.png",
"pricing_model": "subscription",
"category": "islamic"
}/s/{url_slug}/otp/requestSend OTP to a phone number. Rate limited to 10/hour per phone.No Auth
// Request
{ "phone": "01712345678" }
// Response — OTPRequestOut
{
"status_code": "S1000",
"status_detail": "Success",
"raw": { /* full BDApps response */ },
"reference_no": "bdapps_ref_abc123"
}/s/{url_slug}/otp/verifyVerify OTP and create the subscription.No Auth
// Request
{ "reference_no": "bdapps_ref_abc123", "otp": "1234" }
// Response — CheckoutOTPVerifyOut
{
"status_code": "S1000",
"status_detail": "Success",
"raw": { /* full BDApps response */ },
"subscription_status": "REGISTERED",
"subscriber_id": "tel:8801712345678",
"local_subscriber_id": "550e8400-...",
"redirect_url": "https://yourapp.com/welcome"
}Phone number formats
Accepts 01XXXXXXXXX, 8801XXXXXXXXX, or +8801XXXXXXXXX — with or without spaces and dashes.
Webhooks
Configure a webhook URL in your app settings to receive real-time events. Subscribe to only the events you care about — only events in events: [...] are delivered.
Event Catalog
Subscription lifecycle
subscriber.createdBDApps reported REGISTERED for a subscriber who wasn't previously cancelledsubscriber.reactivatedBDApps reported REGISTERED for a previously cancelled subscribersubscriber.cancelledBDApps reported UNREGISTEREDsubscriber.<status>Any other BDApps status, lowercased (e.g. subscriber.pending). Match this prefix so a new upstream status doesn't break your handlerInbound messaging (from BDApps webhooks)
sms.receivedIncoming SMS from a subscriberussd.receivedIncoming USSD messageThat is the complete set of webhook-delivered events. Hosted checkout also records checkout.otp.requested and checkout.otp.verify.failed, but those are written to your app's event log for the dashboard only — they are not forwarded to your webhook URL. To react to a checkout that succeeds, listen for subscriber.created.
Subscription Event Payload
POST https://your-server.com/webhooks/appspro
Headers:
Content-Type: application/json
X-Event-Type: subscriber.created
X-Event-Id: 3f9c1e6a-... // stable across retries of one delivery
X-Timestamp: 1767225600 // unix seconds, part of the signed string
X-Body-Sha256: 9b2e... // sha256 of the exact bytes sent
X-Signature: 7c1f... // hex HMAC-SHA256, NO "sha256=" prefix
Body — every event uses this envelope; only "data" varies:
{
"event_id": "3f9c1e6a-...",
"event_type": "subscriber.created",
"occurred_at": "2026-05-11T10:30:00+00:00",
"application_id": "550e8400-...", // your AppsPro app id (UUID)
"is_sandbox": false,
"data": {
"subscriber_id": "tel:8801712345678", // BDApps subscriber id
"local_subscriber_id": "9c8b7a6d-...", // AppsPro Subscriber.id
"external_user_id": "your-user-123", // echoed if you sent one
"status": "REGISTERED",
"frequency": "daily",
"subscribed_at": "2026-05-11T10:30:00+00:00",
"cancelled_at": null
}
}SMS Event Payload
{
"event_id": "1a2b3c4d-...",
"event_type": "sms.received",
"occurred_at": "2026-05-11T10:31:00+00:00",
"application_id": "550e8400-...",
"is_sandbox": false,
"data": {
"applicationId": "BDAPPS_123",
"sourceAddress": "tel:8801712345678",
"message": "Hello from subscriber",
"requestId": "req_xyz",
"encoding": "0"
}
}Signature Verification
Every delivery carries an X-Signature header: the hex HMAC-SHA256 of f"{X-Timestamp}." + raw_body — the timestamp, a literal dot, then the exact bytes we sent.
Sign the raw request body, not a re-serialised copy of the parsed JSON. We sign what goes on the wire, so any round-trip through your JSON parser risks changing the bytes and breaking the comparison. Read the body as bytes before your framework decodes it.
The key is your webhook signing secret if you set one in the dashboard, otherwise your app's secret_key (the same key you use as the SDK Bearer token).
import hashlib, hmac, time
MAX_SKEW_SECONDS = 300 # reject anything older than 5 minutes
def verify(raw_body: bytes, headers, signing_key: str) -> bool:
timestamp = headers["X-Timestamp"]
# 1. Reject stale deliveries (replay protection).
if abs(time.time() - int(timestamp)) > MAX_SKEW_SECONDS:
return False
# 2. Independent transit check — no secret involved.
if not hmac.compare_digest(
hashlib.sha256(raw_body).hexdigest(), headers["X-Body-Sha256"]
):
return False
# 3. The signature itself, over "<timestamp>." + the raw bytes.
expected = hmac.new(
signing_key.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, headers["X-Signature"])In FastAPI that raw body is await request.body(); in Express, mount express.raw({ type: 'application/json' }) on the webhook route so req.body stays a Buffer.
Code Examples
Verify Subscription (Python)
import requests
response = requests.get(
"https://api.appspro.dev/api/v1/sdk/verify/tel:8801712345678",
headers={"Authorization": f"Bearer {SECRET_KEY}"},
)
data = response.json()
if data["valid"]:
print(f"Active subscriber: {data['subscriber']['id']}")
else:
print(f"Invalid: {data['reason']}")Verify Subscription (JavaScript)
const res = await fetch(
"https://api.appspro.dev/api/v1/sdk/verify/tel:8801712345678",
{ headers: { Authorization: `Bearer ${secretKey}` } }
);
const data = await res.json();
if (data.valid) {
console.log("Active subscriber:", data.subscriber.id);
} else {
console.log("Invalid:", data.reason);
}Handle Webhook (Node.js / Express)
import crypto from "crypto";
import express from "express";
const app = express();
// The signature covers the RAW bytes, so this route must NOT use
// express.json() — keep the body as a Buffer.
app.post(
"/webhooks/appspro",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.headers["x-signature"] || "";
const timestamp = req.headers["x-timestamp"] || "";
const eventType = req.headers["x-event-type"];
// Reject stale deliveries (replay protection).
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.status(401).send("Stale timestamp");
}
// Sign "<timestamp>." + the raw body bytes.
const signed = Buffer.concat([Buffer.from(timestamp + "."), req.body]);
const expected = crypto
.createHmac("sha256", process.env.SIGNING_KEY)
.update(signed)
.digest("hex");
if (
signature.length !== expected.length ||
!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
) {
return res.status(401).send("Invalid signature");
}
const body = JSON.parse(req.body.toString("utf8"));
switch (eventType) {
case "subscriber.created":
console.log("New subscriber:", body.data.subscriber_id);
break;
case "subscriber.cancelled":
console.log("Unsubscribed:", body.data.subscriber_id);
break;
case "subscriber.reactivated":
console.log("Reactivated:", body.data.subscriber_id);
break;
case "sms.received":
console.log("SMS from:", body.data.sourceAddress);
break;
}
res.sendStatus(200);
}
);Handle Webhook (Python / FastAPI)
import hashlib, hmac, json, os, time
from fastapi import FastAPI, Request, HTTPException
app = FastAPI()
SIGNING_KEY = os.environ["SIGNING_KEY"]
@app.post("/webhooks/appspro")
async def receive(req: Request):
raw = await req.body() # the exact bytes we signed
timestamp = req.headers.get("x-timestamp", "")
# Reject stale deliveries (replay protection).
if not timestamp or abs(time.time() - int(timestamp)) > 300:
raise HTTPException(status_code=401, detail="Stale timestamp")
# Independent transit check — no secret involved.
if not hmac.compare_digest(
hashlib.sha256(raw).hexdigest(), req.headers.get("x-body-sha256", "")
):
raise HTTPException(status_code=401, detail="Body digest mismatch")
expected = hmac.new(
SIGNING_KEY.encode(),
f"{timestamp}.".encode() + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, req.headers.get("x-signature", "")):
raise HTTPException(status_code=401, detail="Invalid signature")
body = json.loads(raw)
event = body["event_type"] # also in the X-Event-Type header
data = body["data"]
if event == "subscriber.created":
... # data["subscriber_id"], data["external_user_id"], ...
elif event == "subscriber.cancelled":
... # revoke access
return {"ok": True}List Subscribers (cURL)
curl -H "Authorization: Bearer YOUR_SECRET_KEY" \
"https://api.appspro.dev/api/v1/sdk/subscribers?page=1&limit=20&status=active"WebSDK
Overview
Embed a subscription widget directly in your website or mobile app without redirecting users to a separate page. The widget is injected as plain HTML into your container — no iframe, no postMessage. This makes it reliable in all mobile WebViews (Android/iOS) as well as standard browsers.
How it works
Installation & Usage
1. Include the script
<script src="https://appspro.dev/sdk/v1/appspro.js"></script>2. Add a container and initialize
<!-- In your HTML -->
<div id="subscribe-box"></div>
<script>
const sdk = AppsPro('YOUR_PUBLIC_KEY', {
baseUrl: 'https://api.appspro.dev', // required in production
});
const el = sdk.elements.create('subscribe', {
buttonText: 'Subscribe Now',
buttonColor: '#3b82f6',
theme: 'light', // 'dark' | 'light'
borderRadius: '16px',
});
el.mount('#subscribe-box');
el.on('ready', () => console.log('Widget ready'));
el.on('otp-sent',(data) => console.log('OTP sent to', data.phone));
el.on('success', (data) => {
console.log('Subscribed!', data.subscriberId);
// Verify on your server:
// GET /api/v1/sdk/verify/<data.subscriberId>
});
el.on('payment-redirect', (data) => location.href = data.url);
el.on('error', (err) => console.error('Error:', err.message));
</script>Options
SDK init
AppsPro(publicKey, {
baseUrl: string, // required: 'https://api.appspro.dev'
// the SDK file is on appspro.dev but the API lives on
// api.appspro.dev, so this must be set explicitly
})elements.create('subscribe', ...)
sdk.elements.create('subscribe', {
buttonText: string, // default: 'Subscribe Now'
buttonColor: string, // default: '#3b82f6' (CSS color)
theme: string, // 'dark' | 'light', default: 'light'
borderRadius: string, // default: '16px'
logoUrl: string, // override the "Powered by AppsPro" footer mark
})compact and hide_header are query parameters of the /embed/subscribe iframe, not options of this element — passing them to elements.create() has no effect.
Events
| Event | Payload | Description |
|---|---|---|
| ready | {} | Widget has rendered in the DOM |
| otp-sent | { phone } | OTP was sent to the phone |
| success | { subscriberId, localSubscriberId, redirectUrl } | User subscribed successfully |
| payment-redirect | { url } | Redirect URL is available (forwarded from OTP verify response) |
| error | { message } | An error occurred in the flow |
Checkout Helpers
If you don't want to embed the widget, send the user to the hosted checkout page. These helpers resolve your url_slug and build https://appspro.dev/s/<url_slug>.
const sdk = AppsPro('YOUR_PUBLIC_KEY', {
baseUrl: 'https://api.appspro.dev',
});
// Returns a Promise — the url_slug is fetched from /sdk/app-info.
const url = await sdk.createCheckoutUrl();
// → "https://appspro.dev/s/Hd3kF9aZ2x"
// Already know your slug? Skip the round trip:
const url2 = await sdk.createCheckoutUrl({ urlSlug: 'Hd3kF9aZ2x' });
// Open in a popup window (returns the window handle synchronously,
// then navigates it once the URL resolves — so popup blockers don't fire).
sdk.openCheckout({ width: 460, height: 600 });
// Update an already-mounted element's options
const el = sdk.elements.create('subscribe', { theme: 'dark' });
el.mount('#box');
el.update({ buttonColor: '#10b981' });
el.unmount();There is no redirect option. Where the user lands after subscribing is your app's checkout_redirect_url, set in the dashboard's settings tab — accepting one from the caller would turn /s/* into an open redirect.
Requires SDK v1.5+. In v1.4 and earlier createCheckoutUrl() returned a string, and that string always 404'd.
WebView Integration (legacy)
Flutter apps should use the native Flutter SDK instead. Keep this approach only for hosts where a native package is not an option — a Cordova or Capacitor shell, or an existing WebView you already ship. The SDK calls a JavaScript channel named AppsPro with every event, so no extra wiring is needed on the JS side.
import 'dart:convert';
import 'package:webview_flutter/webview_flutter.dart';
class SubscribeWebView extends StatefulWidget {
final String publicKey;
const SubscribeWebView({required this.publicKey, super.key});
@override
State<SubscribeWebView> createState() => _SubscribeWebViewState();
}
class _SubscribeWebViewState extends State<SubscribeWebView> {
late final WebViewController _controller;
@override
void initState() {
super.initState();
_controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..addJavaScriptChannel(
'AppsPro', // SDK calls window.AppsPro.postMessage() automatically
onMessageReceived: (msg) {
final data = jsonDecode(msg.message) as Map<String, dynamic>;
if (data['type'] == 'success') {
final subscriberId = data['data']['subscriberId'];
Navigator.pop(context, subscriberId);
}
},
)
..loadHtmlString(_buildHtml());
}
String _buildHtml() => """
<!DOCTYPE html><html><head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<script src="https://appspro.dev/sdk/v1/appspro.js"></script>
</head><body style="margin:0;background:#0f0f13">
<div id="sub"></div>
<script>
const sdk = AppsPro('${widget.publicKey}', { baseUrl: 'https://api.appspro.dev' });
const el = sdk.elements.create('subscribe', { hideHeader: true });
el.mount('#sub');
</script></body></html>""";
@override
Widget build(BuildContext context) => WebViewWidget(controller: _controller);
}Flutter SDK
appspro_sdk gives Flutter apps the same subscription flow natively — no WebView. The widgets are Material 3 and take their colours, shapes and type from your app's ThemeData, and the phone and OTP fields opt into the platform's own autofill, so iOS can pull the code straight from the SMS banner.
Android, iOS, web, macOS, Windows and Linux. pub.dev · source
Install and configure
Only the publishable key goes in your app. Your secret key authorises server-to-server calls and signs your webhooks — it must never ship in a binary.
dependencies:
appspro_sdk: ^0.1.0import 'package:appspro_sdk/appspro_sdk.dart';
void main() {
AppsProClient.configure(publishableKey: 'YOUR_PUBLISHABLE_KEY');
runApp(const MyApp());
}Subscribe
// A full screen — the one-line integration.
final result = await AppsProSubscribeScreen.show(context);
if (result?.isSubscribed ?? false) unlockPremium();
// Or inline, inside your own paywall.
AppsProSubscribeCard(
onSuccess: (result) => context.go('/premium'),
onError: (message) => debugPrint(message),
)Gate content and let users cancel
Both app stores expect a subscription to be cancellable inside the app that sold it. Access is still a server decision — verify entitlement on your own server before handing out anything valuable.
AppsProSubscriptionGate(
builder: (context) => const PremiumScreen(),
fallback: (context, subscribe) => Paywall(onTap: subscribe),
)
// Status and cancellation, for your settings screen.
await AppsProManageScreen.show(context);Your own UI
SubscribeController is the state machine behind the widgets. Drive it directly when you want your own screens but not your own protocol handling.
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: // ...
}Route through your own server
By default the package calls AppsPro directly with the publishable key. If you already have a backend and a signed-in user, proxy the calls instead: your server holds the secret key, so it can tie a subscription to your own user id and authorise and log every call. Implement five routes — see the package README for a reference proxy in Express and FastAPI.
AppsProClient.configure(
publishableKey: 'YOUR_PUBLISHABLE_KEY',
backend: AppsProProxyBackend(
baseUrl: 'https://api.mystore.com',
headers: () async => {'Authorization': 'Bearer ${await session.token()}'},
),
);
// Or override just one call and leave the rest direct:
AppsProClient.configure(
publishableKey: 'YOUR_PUBLISHABLE_KEY',
statusResolver: () async => myBackend.subscriptionStatus(),
);Test without charging anyone
Create a sandbox app from the API & SDK tab of your app. Sandbox keys talk to a mock carrier: no SMS, no charges, and the OTP is always 000000. The sandbox app and its keys are deleted automatically once your app is published, so make sure your release build uses the production key.
flutter run --dart-define=APPSPRO_PUBLISHABLE_KEY=pk_your_sandbox_keyClient-safe endpoints
What the package calls. Everything else under /api/v1/sdk/* needs the secret key and belongs on a server.
/api/v1/sdk/app-infoApp name, icon, carriers and the price disclosure. Query: publishable_key.No Auth
/s/{url_slug}/otp/requestSend the OTP. 10/hour per phone per app.No Auth
/s/{url_slug}/otp/verifyVerify and subscribe. Returns a subscription_token.No Auth
/s/{url_slug}/subscriptionRead the token holder's own subscription. Auth: Bearer <subscription_token>. Rotates the token on every read.
/s/{url_slug}/unsubscribeCancel the token holder's own subscription. Auth: Bearer <subscription_token>.
Embed
A standalone HTML checkout page hosted by AppsPro, useful when you need an iframe rather than the inline WebSDK injection. Returns HTML.
/embed/subscribeIframe-friendly subscribe page. Query: publishable_key, token (from /sdk/generate-token), theme, locale, button_text, button_color, compact, hide_header.No Auth
<iframe
src="https://api.appspro.dev/embed/subscribe?publishable_key=YOUR_PUBLISHABLE_KEY&theme=dark&button_text=Subscribe"
style="border:0;width:100%;height:600px"
allow="clipboard-write"
></iframe>AI Agent / LLM Docs
Copy the markdown below and paste it into your AI assistant (Claude, ChatGPT, Cursor, etc.) to give it full context about the AppsPro API.
# AppsPro API — Customer Reference for AI Agents
AppsPro is a subscription-management platform built on top of Dialog Axiata BDApps.
This document covers the **customer-facing** API surface only — what a developer
integrating their app with AppsPro can call. Internal dashboard endpoints are
not included.
Base URL: https://api.appspro.dev
SDK Base URI (shown in your dashboard): https://api.appspro.dev/api/v1
---
## Credentials (issued per app in the AppsPro dashboard)
- publishable_key — client-side safe (e.g. "pk_..."). Used in WebSDK init
code (AppsPro('pk_...')) and as a query param on the
/sdk/app-info and /embed/subscribe public endpoints.
- secret_key — server secret (e.g. "sk_..."). The Authorization
Bearer token for /api/v1/sdk/* calls AND the HMAC-SHA256
key for verifying outbound webhook signatures. Shown
once on regenerate.
- url_slug — short 10-char base62 handle (e.g. "Hd3kF9aZ2x"). Used
in user-facing checkout URLs: appspro.dev/s/<slug>.
Stable across credential rotations.
You never use an "app_id" path parameter from your side. /sdk/* endpoints
identify your app via the Bearer key; checkout URLs identify it via the
url_slug; the WebSDK identifies it via the publishable_key.
---
## Auth schemes by route group
- /api/v1/sdk/{verify,subscribers,status,otp/*,subscribe,unsubscribe}
→ Bearer (Authorization: Bearer sk_<secret>)
- /api/v1/sdk/app-info, /api/v1/sdk/generate-token
→ no auth (uses publishable_key in body / query)
- /discover/* — no auth (public marketplace)
- /s/{url_slug}/* — no auth (hosted checkout, called by browser)
- /bdapps/* — no auth (called by BDApps, not by you — configured in BDApps portal)
- /embed/* — no auth (token from /sdk/generate-token instead)
- /public/unsubscribe/* — no auth (end-user UI flow, not for customer code)
---
## BDApps Portal Configuration
In your BDApps developer portal, paste these URLs:
SMS MO URL: https://api.appspro.dev/bdapps/sms
USSD MO URL: https://api.appspro.dev/bdapps/ussd
Notify URL: https://api.appspro.dev/bdapps/notify
Report URL: https://api.appspro.dev/bdapps/report
These URLs are identical for every customer. AppsPro routes each incoming
payload to your app via BDApps' applicationId field.
Whitelist this source IP in the BDApps portal so we can reach you:
217.15.160.79
You can also read these values programmatically:
GET /platform/bdapps-portal-config
Response: { sms_url, ussd_url, notify_url, whitelisted_ips }
---
## SDK (Bearer auth or public)
GET /api/v1/sdk/verify/{subscriber_id} // Bearer
subscriber_id must be the BDApps subscriber ID returned by /sdk/subscribers,
e.g. "tel:8801712345678" — not the internal AppsPro UUID.
Response: { valid: bool, subscriber?: SubscriberResponse, reason?: string }
POST /api/v1/sdk/verify/batch // Bearer
Body: { subscriber_ids: string[] } // up to 500 per call
Response: { results: [{ subscriber_id, valid, status?, reason? }] }
// Bulk form of GET /verify/{id}, for reconcilers. Reads the local mirror
// only — no upstream BDApps call, so it's cheap to run on a schedule.
GET /api/v1/sdk/subscribers?page=&limit=&status=
Response: { subscribers: SubscriberResponse[], total, page, limit }
DELETE /api/v1/sdk/subscribers/by-external-id/{external_user_id} // Bearer
204 on success.
// GDPR scrub: removes the phone + external_user_id from the Subscriber
// row. Call your unsubscribe first — this does NOT cancel carrier billing.
POST /api/v1/sdk/spawn-sandbox // Bearer (production key only)
Response: { app_id, publishable_key, secret_key, created }
// Creates (or re-fetches) the sandbox twin of the calling app. Idempotent:
// a second call returns the same credentials with created=false.
// 409 if the calling key already belongs to a sandbox app.
POST /api/v1/sdk/status // Bearer
Body: { phone } // raw "01XXXXXXXXX"
Response: { subscription_status, status_code, status_detail, raw }
// Queries BDApps live (not local DB) so it's truth even before notify webhook.
POST /api/v1/sdk/otp/request // Bearer Rate limit 10/h/phone/app.
Body: { phone, external_user_id? }
Response: { reference_no, status_code, status_detail, raw }
// Sends a real SMS. Pass reference_no back to /sdk/otp/verify.
POST /api/v1/sdk/otp/verify // Bearer
Body: { reference_no, otp, external_user_id? }
Response: { subscription_status, subscriber_id, local_subscriber_id, status_code, status_detail, raw }
// On success, registers the subscriber locally. It does NOT itself fire a
// webhook: subscriber.created is emitted when BDApps posts its confirming
// callback to /bdapps/notify, which can lag this response. Use the response
// to unlock access; use the webhook to reconcile.
POST /api/v1/sdk/subscribe // Bearer
Body: { phone, external_user_id? }
Response: { status_code, status_detail, raw }
// Direct subscribe without OTP. For trusted server-to-server use.
POST /api/v1/sdk/unsubscribe // Bearer
Body: { phone, external_user_id? }
Response: { status_code, status_detail, raw }
// Success if statusCode == "S1000" OR raw.subscriptionStatus == "UNREGISTERED".
external_user_id (optional, max 100 chars) is your own identifier for the
user. It is stored on the Subscriber and echoed back in webhook payloads,
so you can match deliveries without normalising phone numbers.
GET /api/v1/sdk/app-info?publishable_key=... // no auth (legacy ?public_key= alias accepted)
Response: {
name, description, category, pricing_model, icon_url,
organization, // developer's org name, falls back to their name
publishable_key,
client_id, // == url_slug; the path segment for /s/{slug}/...
url_slug, // same value, both names are returned
pricing: [{ method_name, display_name, price, price_inclusive,
billing_period }],
payment_method_icons: [{ method_name, carrier_name, icon_url }],
is_sandbox, // true when this key belongs to a sandbox app
disclosure_md, // BDApps-required pricing disclosure line
supported_countries // ["BD"] — BDApps is Bangladesh-only for now
}
POST /api/v1/sdk/generate-token // no auth, scoped to Origin
Body: { publishableKey } // accepts legacy publicKey too
Response: { token, expires_in: 300 }
SubscriberResponse:
{ id, bdapps_subscriber_id, phone_masked, status, subscription_type, frequency, subscribed_at, cancelled_at, created_at }
---
## Hosted Checkout (no auth)
User-facing page: https://appspro.dev/s/{url_slug}
GET /s/{url_slug}/info
Response: { name, description, icon_url, banner_url, pricing_model, category }
POST /s/{url_slug}/otp/request
Body: { phone, external_user_id? }
Response: { status_code, status_detail, raw, reference_no }
POST /s/{url_slug}/otp/verify
Body: { reference_no, otp, external_user_id? }
Response: { status_code, status_detail, raw, subscription_status, subscriber_id,
local_subscriber_id, redirect_url, download_token, subscription_token }
Phone formats accepted: 01XXXXXXXXX, 8801XXXXXXXXX, or +8801XXXXXXXXX.
### Subscription session (client-safe status + cancel)
A successful /otp/verify also returns "subscription_token" — a 30-day JWT
bound to (app_id, subscriber_id). It authorises the two routes below, which
exist so a client that CANNOT hold the secret key (a mobile app) can still
read and cancel its own subscription. The token only ever addresses the one
subscription its holder proved via OTP, so it grants strictly less than the
secret key does. Store it; the server rotates it on every read.
GET /s/{url_slug}/subscription // Auth: Bearer <subscription_token>
Query: upstream=true to force a live BDApps lookup instead of the local
mirror (costs a TAP round-trip; the mirror is kept current by
/bdapps/notify).
Response: { valid, status, reason, subscribed_at, cancelled_at, subscription_token }
Note: valid is TRUE for both "active" and "pending".
401 -> token expired or revoked; 403 -> token belongs to a different app.
POST /s/{url_slug}/unsubscribe // Auth: Bearer <subscription_token>
Body: none. The phone is resolved server-side from the stored subscriber
row, so this cannot be used to unsubscribe an arbitrary number.
Response: { status_code, status_detail, raw }
409 -> no phone recoverable for that subscriber.
---
## Discover / Marketplace (no auth)
Public catalogue of apps that have been published on AppsPro. Useful if
your app is listed and you want to surface its marketplace metadata, or
if you're building an external directory page.
GET /discover/apps?page=&limit=&search=&category=
Response: { apps: DiscoverAppItem[], total, page, limit }
Pagination: page >= 1, limit in [1, 100] (default 20).
GET /discover/apps/{url_slug}
Response: DiscoverAppItem
404 if the slug doesn't match a published app.
GET /discover/apps/{url_slug}/download
302 redirect to the latest APK URL; also increments downloads_count.
404 if the app isn't an Android app or has no published APK.
DiscoverAppItem:
{
name, description,
description_html, // sanitized HTML rendered from the Markdown description
icon_url, banner_url, screenshots, category, pricing_model,
url_slug,
publishable_key, // safe in browser code — lets you reuse /sdk/app-info
developer_name, organization,
downloads_count, // NOT subscriber_count
app_type, // "android" | "web"
apk_url, web_url,
requires_subscription, // true when the APK download is gated behind a sub
versions: [{ version_name, release_notes, created_at }],
pricing: [{ method_name, display_name, price, price_inclusive,
billing_period, carrier_label }],
tax_rates: { ... } // current VAT/SD/SC percentages, for a breakdown tooltip
}
price — pre-tax base price as entered by the merchant
price_inclusive — base + VAT + SD + SC, computed for display
carrier_label — carrier name(s) behind the method ("Robi/Airtel"),
resolved server-side so an admin rename reaches you
---
## Webhooks (your server receives POSTs from AppsPro)
Configure your webhook URL and event list in the AppsPro dashboard.
Headers on every delivery:
Content-Type: application/json
X-Event-Type: <event name>
X-Event-Id: <UUID, stable across retries of the same delivery>
X-Timestamp: <unix seconds, part of the signed string>
X-Body-Sha256: <sha256 hex of the exact bytes sent>
X-Signature: <hex HMAC-SHA256, NO "sha256=" prefix>
SIGNATURE — sign the RAW REQUEST BYTES, not a re-serialised parse:
signed_string = f"{X-Timestamp}.".encode() + raw_body
expected = hmac.new(key, signed_string, hashlib.sha256).hexdigest()
key = the webhook signing_secret if set in the dashboard,
else the app's secret_key
Also verify sha256(raw_body) == X-Body-Sha256, and reject deliveries whose
X-Timestamp is more than 5 minutes from your clock.
Do NOT re-serialise the parsed JSON and hash that — we sign what we put on
the wire, so a parse/re-dump round trip will not reproduce the same bytes.
Event types (this is the complete set that is DELIVERED):
subscription lifecycle (from the BDApps notify callback):
subscriber.created // REGISTERED, not previously cancelled
subscriber.reactivated // REGISTERED, was cancelled
subscriber.cancelled // UNREGISTERED
subscriber.<status> // any other status, lowercased — match the
// prefix so a new upstream status doesn't break you
inbound messaging (forwarded from BDApps):
sms.received, ussd.received
NOT delivered: checkout.otp.requested and checkout.otp.verify.failed are
written to the app's internal event log for the dashboard only. To react to
a successful hosted checkout, listen for subscriber.created — but note it is
emitted from the inbound /bdapps/notify callback, not from OTP verify, so it
lags a successful subscribe. A client polling GET /s/{url_slug}/subscription
sees "pending" during that window, which counts as subscribed.
Every event uses the same envelope; only "data" varies:
{
"event_id": "3f9c1e6a-...",
"event_type": "subscriber.created",
"occurred_at": "2026-05-11T10:30:00+00:00",
"application_id": "550e8400-...", // your AppsPro app id (UUID)
"is_sandbox": false,
"data": {
"subscriber_id": "tel:8801712345678",
"local_subscriber_id": "9c8b7a6d-...",
"external_user_id": "your-user-123",
"status": "REGISTERED",
"frequency": "daily",
"subscribed_at": "2026-05-11T10:30:00+00:00",
"cancelled_at": null
}
}
For sms.received / ussd.received the envelope is identical and "data" is the
raw BDApps MO payload ({ applicationId, sourceAddress, message, requestId,
encoding } for SMS).
---
## Embed (no auth)
GET /embed/subscribe?publishable_key=&token=&theme=&locale=&button_text=&button_color=&compact=&hide_header=
→ HTML page suitable for iframe embedding
token comes from POST /api/v1/sdk/generate-token
(legacy ?public_key= query is still accepted)
---
## WebSDK (browser JS — appspro.js)
Embed a subscription widget directly in your website or mobile WebView.
The widget is injected as plain HTML into your container — no iframe, no
postMessage — so it works reliably in Android/iOS WebViews.
### Install
<script src="https://appspro.dev/sdk/v1/appspro.js"></script>
// Legacy URL /sdk/v1/texionapps.js is still served as a symlink to
// appspro.js but is deprecated — use the new filename in new pages.
### Init
const sdk = AppsPro(publicKey, { baseUrl });
- publicKey: your publishable_key (e.g. "pk_..."). Safe to ship to the browser.
- baseUrl (required): the API host, "https://api.appspro.dev". The SDK does
not auto-detect this — the script is served from appspro.dev but the API
lives on api.appspro.dev, so it must be passed explicitly.
### Create the subscribe element
const el = sdk.elements.create('subscribe', {
buttonText: string, // default: 'Subscribe Now'
buttonColor: string, // default: '#3b82f6' (CSS color)
theme: 'dark' | 'light', // default: 'light'
borderRadius: string, // default: '16px'
logoUrl: string, // override the "Powered by AppsPro" footer mark
});
NOTE: compact and hideHeader are NOT element options — they are
query params of the /embed/subscribe iframe (compact, hide_header) and
are ignored by elements.create(). The iframe surface uses snake_case
(button_text, button_color) and defaults to theme=dark,
button_color=#6366f1; the WebSDK element uses camelCase and defaults to
theme=light, buttonColor=#3b82f6. Don't mix the two.
### Element API
el.mount(selector); // selector string, e.g. '#subscribe-box'
el.unmount(); // remove from DOM
el.update(opts); // change options on a mounted element
el.on(event, cb); // subscribe to events (see below)
mount() fetches /api/v1/sdk/app-info under the hood to render the header.
### Events
ready → {} — widget has rendered in the DOM
otp-sent → { phone } — OTP SMS was dispatched
success → { subscriberId, localSubscriberId, redirectUrl } — user subscribed
payment-redirect → { url } — forwarded from OTP verify
error → { message } — anything failed in the flow
On 'success', verify server-side before granting paid access:
GET /api/v1/sdk/verify/{subscriberId} // Bearer sk_...
### Checkout helpers (no widget)
If you don't want to embed the widget, send the user to the hosted checkout
page at https://appspro.dev/s/<url_slug>. Requires SDK v1.5+.
const url = await sdk.createCheckoutUrl();
// → "https://appspro.dev/s/Hd3kF9aZ2x"
// Returns a Promise: the url_slug comes from /sdk/app-info. Pass it
// explicitly to skip that round trip.
const url2 = await sdk.createCheckoutUrl({ urlSlug: 'Hd3kF9aZ2x' });
sdk.openCheckout({ width: 460, height: 600 });
// Opens a centered popup named 'appspro-checkout'. The window is opened
// synchronously and navigated once the URL resolves, so popup blockers
// don't fire.
There is no redirect option. The post-subscribe destination is the app's
checkout_redirect_url, set in the dashboard settings tab; taking one from
the caller would make /s/* an open redirect.
In v1.4 and earlier createCheckoutUrl() returned a string built as
"<baseUrl>/checkout?public_key=..." — a route that does not exist, on the
API host rather than the web host. Every URL it returned 404'd.
### Mobile WebView (legacy — prefer the native Flutter SDK below)
The SDK auto-posts every event to a JavaScript channel named "AppsPro":
window.AppsPro.postMessage(JSON.stringify({ type, data }));
Register a channel of that exact name and parse the JSON to receive
ready/otp-sent/success/payment-redirect/error events. No extra JS wiring is
needed inside the WebView. For Flutter apps use appspro_sdk instead; keep
this for Cordova/Capacitor shells or an existing WebView you already ship.
---
## Flutter SDK (pub.dev — appspro_sdk)
Native Flutter package. Same flow as the WebSDK, no WebView. Material 3
widgets that inherit the host app's ThemeData. Supports Android, iOS, web,
macOS, Windows and Linux.
Package: https://pub.dev/packages/appspro_sdk
Source: https://github.com/texion-tech/appspro-sdk-flutter
### Install
# pubspec.yaml
dependencies:
appspro_sdk: ^0.1.0
### Configure (once, in main())
import 'package:appspro_sdk/appspro_sdk.dart';
void main() {
AppsProClient.configure(publishableKey: 'pk_...');
runApp(const MyApp());
}
Only the PUBLISHABLE key goes in the app. The secret key authorises
server-to-server calls and signs webhooks — never ship it in a binary.
Optional named args: baseUrl (defaults to https://api.appspro.dev),
backend, tokenStore, statusResolver, unsubscribeHandler, externalUserId.
### Widgets
// Full screen — the one-line integration. Returns null if dismissed.
final result = await AppsProSubscribeScreen.show(context);
if (result?.isSubscribed ?? false) unlockPremium();
// Inline card, for your own paywall screen.
AppsProSubscribeCard(
onSuccess: (SubscriptionResult r) {},
onError: (String message) {},
options: const AppsProOptions(subscribeLabel: 'Start', showBranding: false),
)
// Button that opens the subscribe screen.
AppsProSubscribeButton(onSuccess: (r) {})
// Gate content behind a subscription.
AppsProSubscriptionGate(
builder: (context) => const PremiumScreen(),
fallback: (context, subscribe) => Paywall(onTap: subscribe),
)
// Status + cancel. Both app stores expect in-app cancellation.
await AppsProManageScreen.show(context);
### Headless client
final client = AppsProClient.instance;
final AppInfo info = await client.appInfo();
final OtpRequestResult otp = await client.requestOtp('01712345678');
final SubscriptionResult result = await client.verifyOtp(otp.referenceNo!, '123456');
final SubscriptionStatus status = await client.subscriptionStatus();
await client.unsubscribe();
### Custom UI — SubscribeController (ChangeNotifier)
final controller = SubscribeController()..load();
controller.addListener(() => setState(() {}));
await controller.submitPhone('01712345678');
await controller.submitOtp('123456');
await controller.resendOtp(); // no-op during the cooldown
controller.backToPhone();
controller.reset();
Reads: step, appInfo, phone, error, result, isBusy,
resendCooldownRemaining
SubscribeStep: loading | phone | sendingOtp | otp | verifying |
success | failed
There is a 120s resend cooldown by default. The server allows 10 OTPs per
hour per phone per app and each one is a real SMS, so without a cooldown an
impatient user can exhaust the hour and be unable to subscribe at all.
### Route through your own backend
Default is AppsProDirectBackend (publishable key, straight to AppsPro).
To proxy everything through your server — so it holds the secret key, ties
the subscription to your own user id, and authorises 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 (paths configurable via
AppsProProxyPaths). They must speak the same JSON as AppsPro:
GET /appspro/app-info -> the /api/v1/sdk/app-info payload
POST /appspro/otp/request <- { phone, external_user_id? }
POST /appspro/otp/verify <- { reference_no, otp, external_user_id? }
GET /appspro/subscription -> { valid, status, reason? }
POST /appspro/unsubscribe -> any 2xx
A proxying merchant needs NONE of the subscription-token routes: their own
session is the credential, and their server can use the richer Bearer routes
(/api/v1/sdk/verify/{id}, /status, /unsubscribe).
To override only one call and leave the rest direct:
AppsProClient.configure(
publishableKey: 'pk_...',
statusResolver: () async => mySubscriptionStatus(),
);
### Errors — all subclass AppsProException (read .message)
AppsProNetworkException // never reached the server; retry
AppsProInvalidPhoneException // not a valid BD mobile
AppsProInvalidOtpException // wrong/expired code
AppsProRateLimitException // 10 OTPs/hour exhausted
AppsProUnauthorizedException // no valid session; subscribe again
AppsProNotConfiguredException // app has no BDApps credentials
AppsProUnknownAppException // publishable key matches no app
### Testing
Sandbox keys (API & SDK tab -> Sandbox Keys) hit a mock carrier: no SMS, no
charges, OTP is always 000000. The sandbox app and its keys are deleted when
the app is published, so release builds must use the production key.
flutter run --dart-define=APPSPRO_PUBLISHABLE_KEY=pk_sandbox_key
In your own tests, implement AppsProBackend — no HTTP needed:
AppsProClient.configure(publishableKey: 'pk_test', backend: MyFakeBackend());
### Behaviour worth knowing
- Access is a SERVER decision. Verify entitlement on your own server before
handing out anything valuable; a determined user controls their device.
- status "pending" counts as subscribed. The carrier's confirming callback
lags OTP verify, and locking a paying user out in that window is worse.
- The subscription token is rotated on every status read, so an app opened
at least monthly never has to re-run the OTP flow.
- Phone input accepts 01XXXXXXXXX, 8801XXXXXXXXX, +8801XXXXXXXXX and is
validated on-device before any request.
---
## Inbound BDApps webhooks (no auth, called by BDApps — NOT by you)
POST /bdapps/sms // SMS MO callback
POST /bdapps/ussd // USSD MO callback
POST /bdapps/notify // subscription notify
POST /bdapps/report // SMS delivery report
You configure these URLs in the BDApps portal. Do not call them from your code.
---
## Public cross-app unsubscribe (end-user UI)
For end users who want to opt out across every app on the platform from
a single page, AppsPro hosts a public OTP-gated unsubscribe flow:
https://appspro.dev/unsubscribe
Backend routes (POST /public/unsubscribe/otp/request, /otp/verify, and
/public/unsubscribe) are driven by that UI and are not intended to be
called from customer code. From your own code, use:
POST /api/v1/sdk/unsubscribe // Bearer sk_...
---
## Notes
- Phone numbers: 01XXXXXXXXX, 8801XXXXXXXXX, or +8801XXXXXXXXX (Robi/Airtel for BDApps).
- /api/v1/sdk/verify accepts the BDApps subscriber_id from /sdk/subscribers (e.g. tel:8801...).
- secret_key is shown only once when regenerating — store it immediately.
- For browser-side subscription UI, prefer the WebSDK (appspro.js) over
the /embed/subscribe iframe — it works inside Android/iOS WebViews where
iframes routinely break.
- Sending SMS or charging subscribers from your own backend is not part of the
public API. Use the AppsPro dashboard for those operations.