Billing Platform docs Admin →

Getting started

Sell a subscription in about fifteen minutes: create a plan, send the customer to the hosted checkout, and unlock your product when the platform tells you the payment arrived.

Overview

Your backend never talks to Stripe, Notch Pay, PawaPay, EjaraPay, PayPal or Tarapayment directly. It makes three kinds of calls:

  1. Create a checkout session and redirect the customer to its url.
  2. Listen to webhooks such as subscription.activated or invoice.paid.
  3. Ask for entitlements when you need to know whether a customer has access.

Everything in between — the payment page, the gateway, invoices, renewals, retries and reminders — is handled by the platform. Base URL of this installation: https://pay.marthaia.com/v1.

1. Get an API key

Sign in to the admin panel, make sure the environment switcher says test, open Developers and create a secret key. It starts with sk_test_ and is shown once. Keep it on your server only.

export BILLING_KEY=sk_test_…
curl https://pay.marthaia.com/v1/prices -H "Authorization: Bearer $BILLING_KEY"

The key decides the environment: a test key can never touch live data.

2. Create a plan

A product is what you sell; a price says how much, with an amount for every currency you accept (there is no currency conversion). Amounts are integers in minor units: 5000 XAF, 900 = 9.00 USD. You can also do this in Admin → Catalogue.

curl https://pay.marthaia.com/v1/products -H "Authorization: Bearer $BILLING_KEY" \
  -H "Idempotency-Key: create-pro" -H "Content-Type: application/json" \
  -d '{"name": "Pro", "group_key": "plan", "features": {"items": ["Unlimited projects", "Priority support"]}}'

curl https://pay.marthaia.com/v1/prices -H "Authorization: Bearer $BILLING_KEY" \
  -H "Idempotency-Key: create-pro-monthly" -H "Content-Type: application/json" \
  -d '{"product": "prod_…", "type": "recurring", "interval": "month", "nickname": "Pro monthly",
       "lookup_key": "pro_monthly", "amounts": {"XAF": 5000, "XOF": 5000, "USD": 900}}'

Products sharing a group_key (here plan) are exclusive: a customer holds at most one of them, and buying another one replaces the old one when it is paid.

3. Start a checkout

When the user clicks “Subscribe” in your app, create a session and redirect the browser to its url. Use your own order id as the idempotency key so a retried request returns the same session.

curl https://pay.marthaia.com/v1/checkout/sessions -H "Authorization: Bearer $BILLING_KEY" \
  -H "Idempotency-Key: order-8812" -H "Content-Type: application/json" \
  -d '{
    "mode": "subscription",
    "customer": {"external_id": "user_123", "email": "[email protected]", "country": "CM"},
    "items": [{"price": "price_…", "quantity": 1}],
    "currency": "XAF",
    "success_url": "https://app.example.com/billing/return?session={SESSION_ID}",
    "cancel_url": "https://app.example.com/pricing"
  }'
# → {"id": "cs_…", "url": "https://pay.marthaia.com/c/ct_…", "status": "open", …}

The customer is created, or matched by external_id (your user id). Use mode: "payment" for one-off purchases and "setup" to save a payment method without charging.

4. Pay in test mode

Open the checkout url. In test environments the mock gateway is available: choose it, then click Approve on the mock page. Other outcomes (decline, delayed success, OTP required, phone prompt timeout, lost webhook…) are available from the scenario picker, so you can rehearse every case your app must handle. Admin → Developers → Test checkout simulator does all of this in one click.

5. Confirm on return

The browser comes back to your success_url. Never trust the redirect alone: read the session.

curl https://pay.marthaia.com/v1/checkout/sessions/cs_… -H "Authorization: Bearer $BILLING_KEY"
# "status": "complete", "subscription": "sub_…", "invoice": "inv_…"

Mobile money can take a minute: if the status is still open, show “payment pending” and rely on the webhook below.

6. Receive webhooks

Create an endpoint in Admin → Webhooks (or POST /webhook_endpoints) and keep the whsec_… signing secret. Each request carries Billing-Signature: t=<unix>,v1=<hex> where v1 = HMAC-SHA256(secret, t + "." + rawBody).

import express from "express";
import { verifyWebhook } from "billing-platform-sdk";

app.post("/billing/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  let event;
  try {
    event = await verifyWebhook(req.body.toString(), req.get("Billing-Signature"), process.env.BILLING_WEBHOOK_SECRET);
  } catch { return res.sendStatus(400); }
  if (await alreadyHandled(event.id)) return res.sendStatus(200);   // at-least-once delivery
  switch (event.type) {
    case "subscription.activated":
    case "subscription.renewed":  await grantPlan(event.data.object); break;
    case "subscription.past_due": await showPaymentBanner(event.data.object); break;
    case "subscription.canceled": await revokePlan(event.data.object); break;
    case "invoice.paid":          await unlockPurchase(event.data.object); break;
  }
  res.sendStatus(200);
});

Answer 2xx within 10 seconds. Failed deliveries are retried for two days (1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, 48 h) and can be redelivered from the panel.

7. Grant access

Events tell you when something changes; entitlements tell you the current answer. Call it on sign-in or cache it from events.

curl https://pay.marthaia.com/v1/customers/cus_…/entitlements -H "Authorization: Bearer $BILLING_KEY"
# {"data": [{"product_name": "Pro", "status": "past_due", "entitled": true, "reason": "grace_period", …}]}

A past-due customer keeps access during the grace period (7 days by default, Admin → Settings), then loses it.

8. Self-service pages

Customer portal

Plan, payment methods, invoices, cancellation — behind a 30-minute link:

curl https://pay.marthaia.com/v1/portal/sessions -H "Authorization: Bearer $BILLING_KEY" -H "Content-Type: application/json" \
  -d '{"customer": "cus_…", "return_url": "https://app.example.com/account"}'

Pricing page

A hosted plan list with interval and currency switches. Publish the public page in Admin → Settings → Pricing page (https://pay.marthaia.com/pricing/<org>/<project>), or send a signed link to a signed-in user, which shows their current plan and skips the email step:

curl https://pay.marthaia.com/v1/pricing_sessions -H "Authorization: Bearer $BILLING_KEY" -H "Content-Type: application/json" \
  -d '{"customer": {"external_id": "user_123", "email": "[email protected]"}, "success_url": "https://app.example.com/welcome"}'

Payment links

No code at all: create a link in Admin → Payment links (or POST /payment_links) and share it by WhatsApp, email or a button. Each visit opens a fresh checkout. Tick the payment methods to offer, or leave them all unticked to offer what matches the buyer’s country and currency.

curl https://pay.marthaia.com/v1/payment_links -H "Authorization: Bearer $BILLING_KEY" -H "Content-Type: application/json" \
  -d '{"price": "price_…", "currency": "XAF", "allowed_methods": ["gwc_…:mobile_money"]}'
# → {"id": "plink_…", "url": "https://pay.marthaia.com/l/7pmNTPlQB12HnV", …}

No catalogue price for it? Give a custom amount with price_data. The platform creates a hidden product and price for it, so invoices, refunds and reports work as usual; changing the amount later creates a new price and paid invoices keep theirs. These prices never appear on the pricing page.

curl https://pay.marthaia.com/v1/payment_links -H "Authorization: Bearer $BILLING_KEY" -H "Content-Type: application/json" \
  -d '{"price_data": {"name": "Consulting session", "amount": 25000, "currency": "XAF"}}'

9. Go live

  1. Switch the admin panel to the live environment.
  2. Add your real gateway connections in Payment methods and paste each webhook URL into the gateway dashboard.
  3. Recreate products and prices (test and live never share data), a webhook endpoint and an sk_live_ key.
  4. Deploy your app with the live key and the live webhook secret.

JavaScript SDK

The SDK is generated from this installation’s OpenAPI document, so it always matches the reference. It uses fetch, has no dependencies and ships TypeScript types.

# from the platform repository
billing openapi -o openapi.json
billing sdk-js -spec openapi.json -out sdk/js
npm install ./sdk/js
import { Billing } from "billing-platform-sdk";
const billing = new Billing(process.env.BILLING_KEY, { baseUrl: "https://pay.marthaia.com/v1" });

const session = await billing.checkoutSessions.create({
  mode: "subscription",
  customer: { external_id: "user_123", email: "[email protected]" },
  items: [{ price: "price_…" }],
  currency: "XAF",
  success_url: "https://app.example.com/billing/return",
}, { idempotencyKey: "order-8812" });

for await (const inv of billing.invoices.listAutoPaging({ status: "open" })) console.log(inv.number);

POSTs always send an Idempotency-Key (generated when you do not pass one), so the built-in retries on network errors, 429 and 5xx never create duplicates.

API conventions

Payment flows

The hosted checkout handles all of them; headless integrations read next_action from GET /payments/{id}.

next_action.typeCustomer stepGateways
redirectPays on the gateway’s pageNotch Pay, EjaraPay
widgetPicks the operator in a hosted widgetPawaPay
client_secretConfirms in an embedded card / PayPal elementStripe, PayPal
ussd_promptApproves the prompt on the phoneEjaraPay
otp_requiredTypes a code: POST /payments/{id}/authorize_otpEjaraPay
social_linksPays from WhatsApp, Telegram, Dikalo or SMSTarapayment

Subscription lifecycle

incomplete → active when the first invoice is paid. A failed renewal moves it to past_due while retries run (after 1, 3, 5 and 7 days by default); a successful retry brings it back to active. After the last retry it becomes unpaid, then canceled after 30 days. canceled and incomplete_expired are final. Each change emits a subscription.* event.

Errors

{"error": {"type": "invalid_request", "code": "price_currency_unsupported",
           "message": "price price_… has no amount in XOF", "param": "currency", "request_id": "req_…"}}

HTTP 400/422 for invalid requests, 401 bad key, 403 missing scope, 404 unknown object, 409 conflicts, 429 rate limit, 402 payment declined, 502 gateway unavailable. Quote the request_id to support.

Continue to the API reference →