Skip to content

Billing — client integration ​

Everything a storefront needs to sell a plan, take a top-up, change plan and handle renewal. Written for whoever builds the payment screens.

Read this before the billing sections of the Complete Storefront Guide — that page is the full API reference; this one is the order to call things in and the shapes to branch on.

The one rule that trips people up ​

Not every successful call returns a redirectUrl. Some plan changes cost nothing, and some are scheduled rather than paid for now. A client that reads redirectUrl unconditionally will see undefined on a perfectly good 200 and report "cannot get the payment link".

Always branch on the response, never assume a redirect:

ResponseMeaningWhat to do
redirectUrl presentPayment neededSend the user there
switched: trueDone, nothing to payShow the new plan
scheduled: true, no redirectUrlStarts at effectiveAt, nothing to payShow the date
settled: trueA coupon covered the whole priceAlready fulfilled

1. The payment step ​

Ask the server what to render. Never hardcode the list — channels are enabled and disabled from admin, and one that is misconfigured is withheld so you are not showing a button that cannot work.

bash
curl http://localhost:4040/v1/payments/channels     # public, no auth
jsonc
[
  { "slug": "sslcommerz", "name": "Cards & Mobile Banking",
    "autoRenews": false, "requiredFields": [] },

  { "slug": "bkash", "name": "bKash",
    "autoRenews": false,
    "supportsRecurring": true,      // show a "renew automatically" toggle
    "recurringDefault": true,       // start it ticked
    "requiredFields": [
      { "name": "payer", "label": "bKash wallet number", "type": "tel", "optional": true }
    ] },

  { "slug": "dcb", "name": "Mobile Carrier Billing",
    "autoRenews": true,             // always renews itself; say so
    "requiredFields": [
      { "name": "msisdn", "label": "Mobile number", "type": "tel" },
      { "name": "paymentProvider", "label": "Carrier", "type": "select",
        "options": [
          { "value": "GP", "label": "Grameenphone", "needsPin": false },
          { "value": "BL", "label": "Banglalink",   "needsPin": true },
          { "value": "ROBI", "label": "Robi",       "needsPin": false }
        ] }
    ] }
]

Four fields drive the UI:

  • requiredFields — render these and send the answers in extra. Anything without optional: true must be filled or checkout returns 400 naming the field. Do not special-case channels in the client; this is why the list exists.
  • autoRenews — the channel always renews itself. Tell the customer, so a recurring charge is not a surprise.
  • supportsRecurring — the channel can do either. Show a toggle starting at recurringDefault and send the answer as recurring.
  • needsPin on a carrier option — that carrier needs the extra PIN step in §1a. Branch on this flag, never on the carrier code: which operators are on sale is admin-configurable and the list changes.

One entry means one option on screen. bKash is two APIs underneath; you never see that, and you never choose between provider slugs.

Grameenphone and Robi finish on the carrier redirect like any other gateway. Banglalink does not. It creates the subscription, texts the subscriber a five-digit OTP valid for five minutes, and waits. If you send the customer to the redirect and stop there, nothing happens: no charge, no subscription, and no error to show them.

When the chosen carrier has needsPin: true, show a PIN entry screen after checkout returns and post it:

bash
curl -X POST http://localhost:4040/v1/payments/dcb/verify-pin \
  -H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"paymentId":"<from checkout>","pinCode":"41608"}'
jsonc
{ "ok": true, "paymentId": "…", "message": "PIN verification was successful.",
  "subscriptionStatus": "PENDING" }
  • A wrong or expired PIN returns 400 with the carrier's own wording, and details.retryable: true. Let the customer try again — the carrier will send another OTP on request.
  • subscriptionStatus is best-effort and often still says PENDING immediately after a successful verification, because the carrier's status lags the verification by a few seconds. Do not treat that as failure. ok: true is the signal.
  • Activation is still the webhook's job. Poll GET /v1/users/me afterwards, the same as every other gateway.

1b. A plan may not accept every gateway ​

Plans can restrict which channels buy them — a carrier-billed daily pack is registered with the operator at one amount and cannot be charged another way. Most plans restrict nothing.

Read payableWith on the plan and render only those buttons. It is the intersection of what the plan permits with what is actually live, so a plan naming a channel an admin has since switched off will not offer it:

jsonc
// GET /v1/plans
{ "slug": "dcb-daily", "priceCents": 500,
  "allowedChannels": ["dcb"],       // the policy — empty means all
  "payableWith": ["dcb"] }          // what to actually render

The 400 below is the backstop, not the discovery mechanism. Checkout with a disallowed gateway returns it naming what is allowed:

jsonc
{ "error": { "code": "BAD_REQUEST",
  "message": "Plan dcb-daily cannot be paid for with bkash. Allowed: dcb",
  "details": { "requested": "bkash", "allowedChannels": ["dcb"] } } }

bKash one-off and bKash auto-renew count as one channel, so a plan allowing either allows both — nobody is refused for ticking "renew automatically".

2. Selling a plan ​

bash
curl -X POST http://localhost:4040/v1/subscriptions/switch \
  -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
  -d '{
    "planId": "pl_pro",
    "provider": "bkash",
    "recurring": 1,
    "extra": { "payer": "01700000000" }
  }'
# → { "switched": false, "scheduled": false, "recurring": true,
#     "paymentId": "kik-…", "redirectUrl": "https://intent.recurring.pay.bka.sh/…" }

recurring accepts 1/0, true/false and "true"/"false" — whatever your checkbox produces. Omitted means a one-off payment.

Send the user to redirectUrl. There is no synchronous confirmation. The gateway confirms out of band, so after the user returns, poll GET /v1/users/me or GET /v1/subscriptions until the plan changes. Do not treat arriving back on your success page as proof of payment — it is not.

3. Changing plan ​

Price it first so the user sees the number before the redirect:

bash
curl "http://localhost:4040/v1/subscriptions/switch/quote?planId=pl_pro_yearly" \
  -H "Authorization: Bearer $TOK"
# → { "kind": "upgrade", "planPriceCents": 999900,
#     "unusedCreditCents": 99899, "amountDueCents": 900001,
#     "requiresPayment": true, "effectiveAt": "2026-08-09T…" }

Then POST /v1/subscriptions/switch with the same planId. The quote tells you which of two things you are about to do:

Upgrading (kind: "upgrade") is immediate and prorated — the unused remainder of the current plan comes off the price. Returns a redirectUrl; the plan changes when the gateway confirms.

Downgrading (kind: "downgrade", a cheaper or equal plan) is bought now and starts at effectiveAt, when the cycle already paid for runs out. Also returns a redirectUrl, plus scheduled: true. The customer keeps the tier they paid for until then — show effectiveAt, or they will think it failed.

Moving to a free plan is the one change with nothing to pay: scheduled: true and no redirectUrl.

Unused credits always carry over. A trial user with 20 requests left who buys Pro (500/cycle) ends up with 520.

DELETE /v1/subscriptions/:id/scheduled-switch cancels a pending change — but only while it is unpaid. Once paid it answers 400 and support has to reverse it.

4. Top-ups ​

The customer builds the top-up (so many credits, and any other components on sale): price it with POST /v1/topup/quote, buy it with POST /v1/topup/orders (same provider and extra as a plan checkout), poll GET /v1/topup/orders/:id after the redirect. Top-ups are independent of any plan: buyable at any time, on any plan or none, and the tokens land in the same wallet the plan fills. The full flow is in Credits & top-ups.

5. Coupons ​

Price the code before applying it. Codes apply to plans; top-ups take none (their promotions apply automatically):

bash
curl -X POST http://localhost:4040/v1/subscriptions/coupons/preview \
  -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
  -d '{"code":"LAUNCH20","planId":"pl_pro"}'
# → { "originalCents": 29900, "discountCents": 5980,
#     "finalCents": 23920, "currency": "BDT" }

Then pass couponCode to checkout. Send the code, never an amount — the server prices it, so the two cannot disagree.

A rejected code returns 400 with a message safe to show: expired, not yet started, fully redeemed, already used by this customer, below the minimum order, or for the other kind of purchase.

If a code covers the whole price there is nothing to collect: the response comes back settled: true, already fulfilled, and redirectUrl is just your success page.

Coupons are refused on recurring mandates — bKash fixes the amount for every future cycle at signup, so a launch discount would become permanent.

6. Renewal, and what to tell the customer ​

Only some channels renew themselves, and saying the wrong thing costs money either way — a customer told to renew something that auto-renews may pay twice; one told nothing may lapse without meaning to.

  • DCB always renews itself.
  • bKash renews itself when the customer left the toggle ticked.
  • SSLCommerz never does; the customer comes back each cycle.

The platform emails the right message on its own 3 days and 1 day before the cycle ends. In your UI, show what autoRenews and the customer's choice imply.

When a paid cycle ends unpaid, the subscription enters GRACE and access continues for 3 days while they renew. After that it expires and the account drops to the free pay-as-you-go plan — the wallet is not lost, including credits from packs they bought. Treat GRACE as live: it is in the same set as ACTIVE everywhere in the API.

7. Managing auto-renewal ​

GET    /v1/payments/agreements       saved bKash authorisations
DELETE /v1/payments/agreements/:id   stop charging a saved account
DELETE /v1/payments/recurring/:id    cancel a bKash auto-renewing subscription

Cancelling stops future cycles. The cycle already paid for runs to its end — say so, or the customer will expect an immediate refund.

Error responses worth handling ​

StatusMessageCause
400this change costs 999.00 BDT — include a payment providerPaid switch with no provider
400extra.payer: … / extra.msisdn: …A requiredFields entry was not sent
400<slug> is not available right nowChannel disabled or unconfigured in admin
400this amount cannot be charged to a mobile balance — please choose another payment methodCarrier billing has no plan registered at this amount. Permanent for this item: offer another channel, do not retry
400<CARRIER> carrier billing is not available here — choose one of GP|BL|…A carrier not in requiredFields[].options was sent
400already on this planNo-op switch
400a plan change is already paid for and pending — cancel it firstSecond prepaid change
400this change has already been paid for — contact supportCancelling a paid pending change
503Provider <slug> unavailableGateway refused the session; retry or offer another channel

400s carry a message written to be shown to the customer. 503 is transient — offer a different channel rather than a raw error.

kikori.ai — internal documentation