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

1Sign up on appspro.dev, create an app, and link your BDApps credentials (applicationId + password).
2Paste three flat URLs into your BDApps developer portal so BDApps can deliver SMS / USSD / subscription events to AppsPro.
3End-users subscribe through the hosted checkout page — OTP verification is handled for you.
4Verify subscriptions and list subscribers from your backend via the SDK (Bearer auth with your secret_key). Receive signed webhook events for real-time updates.
language
SDK Base URI: 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.

Important: the Unsubscription, Subscriber Status, and SMS APIs require activation by the BDApps administrator for each application — including apps already in production. Requests to these APIs will fail until they have been enabled. Please contact BDApps support to request activation.

Portal URLs

The same values appear in the BDApps Portal Configuration card on your app's Subscription tab.

POSThttps://api.appspro.dev/bdapps/sms

SMS MO callback. Paste into the BDApps portal as the SMS notification URL.No Auth

POSThttps://api.appspro.dev/bdapps/ussd

USSD MO callback. Paste into the BDApps portal as the USSD URL.No Auth

POSThttps://api.appspro.dev/bdapps/notify

Subscription notification callback (subscribe / unsubscribe / renewal events).No Auth

POSThttps://api.appspro.dev/bdapps/report

SMS 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:

GET/platform/bdapps-portal-config

Returns the SMS / USSD / Notify URLs and whitelisted IPs. Useful if you want to validate the dashboard values programmatically.No Auth

json
{
  "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 URI

https://api.appspro.dev/api/v1 — the root for SDK calls

publishable_key

Client-side safe — used in WebSDK init code and /sdk/app-info (e.g. pk_…)

secret_key

Server-side only — Bearer token for SDK calls AND HMAC key for verifying outbound webhook signatures. Shown once on regenerate.

url_slug

Short 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:

bash
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.

http
Authorization: Bearer sk_<your-secret>
warning

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.

GET/api/v1/sdk/verify/{subscriber_id}

Returns subscription validity, subscriber details, and reason if invalid.

json
{
  "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.

GET/api/v1/sdk/subscribers?page=1&limit=20&status=active

Returns paginated subscriber list with total count.

json
{
  "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.

GET/api/v1/sdk/app-info?publishable_key=pk_…

Public app metadata. Used by the WebSDK to render the widget header.No Auth

json
{
  "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.

POST/api/v1/sdk/status

Live BDApps subscription status for a phone number.

json
// 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.

POST/api/v1/sdk/otp/request

Send an OTP SMS to a phone number.

json
// 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.

POST/api/v1/sdk/otp/verify

Verify OTP and register the subscription.

json
// 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.

POST/api/v1/sdk/subscribe

Directly subscribe a phone number.

json
// 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.

POST/api/v1/sdk/unsubscribe

Unsubscribe a phone number.

json
// 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).

POST/api/v1/sdk/generate-token

Issue an embed token for the WebSDK.No Auth

json
// 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

  1. User visits /s/{url_slug} — sees your app info
  2. User enters their Bangladeshi mobile number
  3. OTP is sent via BDApps SMS
  4. User verifies OTP on the checkout page
  5. 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.

GET/s/{url_slug}/info

Public app info for the checkout UI.No Auth

json
// CheckoutAppInfo
{
  "name": "Deendar",
  "description": "Daily Islamic content",
  "icon_url": "https://api.appspro.dev/uploads/icons/abc.png",
  "pricing_model": "subscription",
  "category": "islamic"
}
POST/s/{url_slug}/otp/request

Send OTP to a phone number. Rate limited to 10/hour per phone.No Auth

json
// Request
{ "phone": "01712345678" }

// Response — OTPRequestOut
{
  "status_code": "S1000",
  "status_detail": "Success",
  "raw": { /* full BDApps response */ },
  "reference_no": "bdapps_ref_abc123"
}
POST/s/{url_slug}/otp/verify

Verify OTP and create the subscription.No Auth

json
// 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"
}
info

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 cancelled
subscriber.reactivatedBDApps reported REGISTERED for a previously cancelled subscriber
subscriber.cancelledBDApps reported UNREGISTERED
subscriber.<status>Any other BDApps status, lowercased (e.g. subscriber.pending). Match this prefix so a new upstream status doesn't break your handler

Inbound messaging (from BDApps webhooks)

sms.receivedIncoming SMS from a subscriber
ussd.receivedIncoming USSD message

That 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

json
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

json
{
  "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).

python
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)

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)

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)

javascript
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)

python
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)

bash
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

1Your page loads appspro.js from https://appspro.dev/sdk/v1/appspro.js
2AppsPro(publicKey) creates an SDK instance
3sdk.elements.create('subscribe', options) returns a SubscribeElement
4element.mount('#container') fetches /sdk/app-info and injects the widget inline
5Phone → OTP → success runs in the page; events fire on the element

Installation & Usage

1. Include the script

html
<script src="https://appspro.dev/sdk/v1/appspro.js"></script>

2. Add a container and initialize

html
<!-- 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>
play_circleLive DemoInteractive

Options

SDK init

javascript
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', ...)

javascript
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

EventPayloadDescription
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>.

javascript
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.

dart
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.

yaml
dependencies:
  appspro_sdk: ^0.1.0
dart
import 'package:appspro_sdk/appspro_sdk.dart';

void main() {
  AppsProClient.configure(publishableKey: 'YOUR_PUBLISHABLE_KEY');
  runApp(const MyApp());
}

Subscribe

dart
// 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.

dart
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.

dart
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.

dart
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.

bash
flutter run --dart-define=APPSPRO_PUBLISHABLE_KEY=pk_your_sandbox_key

Client-safe endpoints

What the package calls. Everything else under /api/v1/sdk/* needs the secret key and belongs on a server.

GET/api/v1/sdk/app-info

App name, icon, carriers and the price disclosure. Query: publishable_key.No Auth

POST/s/{url_slug}/otp/request

Send the OTP. 10/hour per phone per app.No Auth

POST/s/{url_slug}/otp/verify

Verify and subscribe. Returns a subscription_token.No Auth

GET/s/{url_slug}/subscription

Read the token holder's own subscription. Auth: Bearer <subscription_token>. Rotates the token on every read.

POST/s/{url_slug}/unsubscribe

Cancel 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.

GET/embed/subscribe

Iframe-friendly subscribe page. Query: publishable_key, token (from /sdk/generate-token), theme, locale, button_text, button_color, compact, hide_header.No Auth

html
<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.

docs.md
# 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.