Getting started
Introduction
The Subscription Gateway is the source of truth for whether a tenant may use a paid product. Requests and responses are JSON under /api/v1.
Create a project in the operator dashboard. That screen shows client_id, client_secret, and the webhook secret once. Store them on your server. The browser and the Android app never see them.
Which id to send
Getting started
Authentication
Exchange the project credentials for a short-lived JWT, then send it as a bearer token. The default lifetime is 3600 seconds. Request a new token when it expires.
Authorization: Bearer <access_token>| Credential | Where it lives |
|---|---|
client_id | Your server. Issued when the project is created or the credential is rotated. |
client_secret | Your server. Shown once. A lost secret is replaced by rotating the credential. |
webhook_secret | Your server. Used only to verify events this gateway posts to you. |
Project isolation
404 not_found.Getting started
Customers
Call POST /api/v1/customers/sync with the id you already store. The pair of project and external_customer_id is the customer. Email and phone can change later.
| Product | external_customer_id |
|---|---|
| Billing | Company id |
| Wastical | Company id |
| School Pilot | School id |
| Guard | Company id |
Optional fields are display_name, email, and phone. After an identity is locked, later syncs keep the stored name, email, and phone.
Access
Entitlement
Read entitled from GET /api/v1/customers/{external_customer_id}/subscription. Open the paid screen only when it is true. A local database flag cannot grant access.
| status | entitled |
|---|---|
trial | Yes, until end_at |
active | Yes, until end_at |
grace_period | Yes, for the project grace window after end_at. Default is 7 days. |
pending_review | Yes, during the provisional window after a proof upload. Default is 3 days. |
pending_payment | No |
expired | No |
Paid window
A successful payment lasts 31 calendar days, counted with day arithmetic. A renewal before expiry extends from the current end_at. A renewal after expiry starts at the new activation time. Approval of a proof while the provisional window is still open starts the 31 days at the provisional start.
When you are unsure
Access
Billing sessions
When entitled is false, create a billing session and send the person to pay_url. They pay as a guest. They do not create an account on this gateway.
Register each return URL on the project first. The gateway compares scheme, host, and path, and ignores one trailing slash. Any other URL is rejected with invalid_return_url.
The code lasts 10 minutes and works once. When the browser returns to your app, read the subscription again. The redirect itself is a navigation event.
Access
Errors
Failures use one envelope.
{
"error": {
"code": "not_found",
"message": "Customer was not found."
}
}| Case | Status | Code |
|---|---|---|
| Wrong client id or secret | 401 | invalid_credentials |
| Missing or expired project token | 401 | unauthorized |
| Unknown customer for this project | 404 | not_found |
| Return URL is not registered | 400 | invalid_return_url |
| Billing code already used or expired | 400 | invalid_code |
| Token endpoint throttled | 429 | rate_limited |
| Missing required field | 400 | invalid |
POST /api/v1/auth/token allows 30 attempts per minute. A throttled response asks you to wait and try again.
Endpoints
Health
Public probes. No authentication.
/healthPublic/api/v1/healthPublic{ "status": "ok" }/readyPublic/ready checks the database connection and returns the same body when the API can serve traffic.
Endpoints
Token
/api/v1/auth/tokenClient secretPOST /api/v1/auth/token
Content-Type: application/json
{
"client_id": "sub_8f3a1c",
"client_secret": "the-secret-shown-once"
}{
"access_token": "<jwt>",
"token_type": "Bearer",
"expires_in": 3600
}Endpoints
Sync customer
/api/v1/customers/syncBearer tokenPOST /api/v1/customers/sync
Authorization: Bearer <access_token>
Content-Type: application/json
{
"external_customer_id": "123",
"display_name": "Acme Stores",
"email": "billing@acme.example",
"phone": "0999123456"
}{
"customer_id": "4f1c0e3a-6b2d-4a7e-9c11-2d8e5a0b7f64",
"external_customer_id": "123"
}Call this before you ask for the subscription, and again when the name, email, or phone changes.
Endpoints
Subscription
/api/v1/customers/{external_customer_id}/subscriptionBearer token{
"external_customer_id": "123",
"subscription_id": "9c2e1a44-1b77-4d0a-8f55-0a6d2c8e91ab",
"status": "active",
"entitled": true,
"payment_required": false,
"start_at": "2026-10-01T08:00:00+00:00",
"end_at": "2026-11-01T08:00:00+00:00",
"amount": "50000.00",
"currency": "MWK",
"is_provisional": false,
"plan_name": "Monthly"
}A customer with no subscription returns status pending_payment, entitled false, and payment_required true. Amounts are decimal strings.
Endpoints
Billing session
/api/v1/billing-sessionsBearer tokenPOST /api/v1/billing-sessions
Authorization: Bearer <access_token>
Content-Type: application/json
{
"external_customer_id": "123",
"return_url": "https://billing.example/billing/return"
}{
"code": "one-time-code",
"expires_in": 600,
"pay_url": "https://<gateway-site>/pay?code=one-time-code"
}After they return
GET /customers/{id}/subscription again. Use entitled from that response.Endpoints
Webhooks
When a project has a webhook URL, this gateway POSTs events to it. Respond with a 2xx status. Delivery retries, then stops after 8 attempts.
your webhook URLHMAC signatureX-Subscription-Signature: t=1760112000,v1=<hex>v1 is HMAC-SHA256 of timestamp + "." + raw body, using the project webhook secret. Compare against the raw bytes before you parse JSON. Ignore a repeat of the same event id.
{
"id": "b7e1c2d0-4a55-4e18-9f20-6c0a1d8e33aa",
"type": "subscription.activated",
"created_at": "2026-10-10T17:00:00+00:00",
"project_id": "1a9c4e22-0b31-4f77-8d10-55e2a0c91f08",
"data": {
"subscription_id": "9c2e1a44-1b77-4d0a-8f55-0a6d2c8e91ab",
"external_customer_id": "123"
}
}| type | When |
|---|---|
payment.succeeded | A payment was confirmed and the paid window was granted. |
subscription.activated | The subscription is in its paid window. |
payment.pending_review | A proof was stored. Access may be provisional until review. |
payment.rejected | A reviewer rejected the proof. The reason is recorded on the payment. |
How a payment is confirmed
The customer pays on pay_url with Airtel Money, TNM Mpamba, a bank transfer, a proof upload, or Wallet. Proof files are stored in the Files gateway. This API keeps the file id.
Airtel, Mpamba, and Standard Bank alerts arrive from Relay. The customer enters the name on the account. A phone number is compared only when both the payment and the SMS include one. A bank alert with no payer name matches only a single waiting bank payment for that amount. The Relay reference can pay once.
Uploading a proof opens review and, while the provisional window is open, temporary access. The payment becomes paid when a reviewer approves it or links an unused Relay reference. Wallet charges are confirmed by reading the charge from Wallet after its signed webhook.
Idempotency
Idempotency-Key header. Reuse the same key when you retry the same attempt so a double-click does not create a second payment.