Integrator reference

Subscription Gateway API

Ask this gateway before you open a paid screen. Your server holds the project credentials. The customer pays through a one-time billing link and never gets an account here.

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

Send the tenant id your product already has. Billing and Wastical send the company id. School Pilot sends the school id. Guard sends the company id. The same person in two products stays two customers.

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.

Request header
Authorization: Bearer <access_token>
CredentialWhere it lives
client_idYour server. Issued when the project is created or the credential is rotated.
client_secretYour server. Shown once. A lost secret is replaced by rotating the credential.
webhook_secretYour server. Used only to verify events this gateway posts to you.

Project isolation

A token can read only the project that issued it. The same external id in another project returns 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.

Productexternal_customer_id
BillingCompany id
WasticalCompany id
School PilotSchool id
GuardCompany 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.

statusentitled
trialYes, until end_at
activeYes, until end_at
grace_periodYes, for the project grace window after end_at. Default is 7 days.
pending_reviewYes, during the provisional window after a proof upload. Default is 3 days.
pending_paymentNo
expiredNo

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

Read the subscription again. The timestamps decide access. A missed webhook cannot leave an expired subscription entitled.

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 envelope
{
  "error": {
    "code": "not_found",
    "message": "Customer was not found."
  }
}
CaseStatusCode
Wrong client id or secret401invalid_credentials
Missing or expired project token401unauthorized
Unknown customer for this project404not_found
Return URL is not registered400invalid_return_url
Billing code already used or expired400invalid_code
Token endpoint throttled429rate_limited
Missing required field400invalid

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.

GET/healthPublic
GET/api/v1/healthPublic
200 response
{ "status": "ok" }
GET/readyPublic

/ready checks the database connection and returns the same body when the API can serve traffic.

Endpoints

Token

POST/api/v1/auth/tokenClient secret
Request
POST /api/v1/auth/token
Content-Type: application/json

{
  "client_id": "sub_8f3a1c",
  "client_secret": "the-secret-shown-once"
}
200 response
{
  "access_token": "<jwt>",
  "token_type": "Bearer",
  "expires_in": 3600
}

Endpoints

Sync customer

POST/api/v1/customers/syncBearer token
Request
POST /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"
}
200 response
{
  "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

GET/api/v1/customers/{external_customer_id}/subscriptionBearer token
200 response
{
  "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

POST/api/v1/billing-sessionsBearer token
Request
POST /api/v1/billing-sessions
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "external_customer_id": "123",
  "return_url": "https://billing.example/billing/return"
}
200 response
{
  "code": "one-time-code",
  "expires_in": 600,
  "pay_url": "https://<gateway-site>/pay?code=one-time-code"
}

After they return

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

POSTyour webhook URLHMAC signature
Request header
X-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.

Event body
{
  "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"
  }
}
typeWhen
payment.succeededA payment was confirmed and the paid window was granted.
subscription.activatedThe subscription is in its paid window.
payment.pending_reviewA proof was stored. Access may be provisional until review.
payment.rejectedA 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

Payment attempts accept an Idempotency-Key header. Reuse the same key when you retry the same attempt so a double-click does not create a second payment.