Skip to content

Recurring billing

Build subscriptions on plain payments: plans, cycles, renewals and dunning.

There is no subscriptions endpoint. There are no plans, no stored mandates, and nothing Wajub debits on a schedule of its own. A subscription is something you build, out of ordinary payments and a job that runs every morning.

That sounds like more work than it is. The whole system is three tables and three jobs, and this guide walks through all of it.

The one constraint that shapes everything

Every Mobile Money charge is confirmed by the customer, on their handset, with their PIN. There is no saved card to charge quietly in the background and no mandate to draw against.

So a renewal is not a silent debit. It is a prompt that arrives on somebody's phone, and if they are asleep, in a meeting or out of credit, it fails.

What you might expectWhat actually happens
Wajub charges the customer each monthYour job creates a payment, the customer confirms it
A failed renewal retries itselfYou decide when and how often to retry
The customer never thinks about itThey confirm every cycle, so they must be warned first

Everything below is designed around that. In particular, the reminder step is not a nicety you add later. A renewal the customer was not expecting is a renewal that fails.

The other route, and where it stands

Invoices carry an is_recurring flag, which is a lighter path for a fixed amount at a fixed interval. Read Recurring invoices before you pick it: the flag is stored and the cycle is configured, but the job that copies an invoice into the next cycle has no first link today, so no second invoice is generated. Until that closes, the architecture on this page is the one that runs.

One cycle, end to end

1. Three tables

A plan is a price and a rhythm. A subscription is one customer on one plan. An invoice is one cycle of one subscription, and it is where every charge attempt is recorded.

The invoice is the table that matters most. It is what makes a renewal idempotent, what a webhook looks itself up by, and what your accountant reads.

The schema
CREATE TABLE plans (
  id            BIGSERIAL PRIMARY KEY,
  name          VARCHAR(120) NOT NULL,
  amount        NUMERIC(12, 2) NOT NULL,
  currency      CHAR(3) NOT NULL,
  interval      VARCHAR(16) NOT NULL,
  trial_days    INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE subscriptions (
  id                    BIGSERIAL PRIMARY KEY,
  customer_id           BIGINT NOT NULL,
  plan_id               BIGINT NOT NULL REFERENCES plans (id),
  status                VARCHAR(16) NOT NULL DEFAULT 'incomplete',
  phone                 VARCHAR(32) NOT NULL,
  email                 VARCHAR(255),
  current_period_start  TIMESTAMPTZ NOT NULL,
  current_period_end    TIMESTAMPTZ NOT NULL,
  cancel_at_period_end  BOOLEAN NOT NULL DEFAULT false,
  cancelled_at          TIMESTAMPTZ
);

CREATE TABLE invoices (
  id               BIGSERIAL PRIMARY KEY,
  subscription_id  BIGINT NOT NULL REFERENCES subscriptions (id),
  kind             VARCHAR(16) NOT NULL,
  amount           NUMERIC(12, 2) NOT NULL,
  currency         CHAR(3) NOT NULL,
  status           VARCHAR(16) NOT NULL DEFAULT 'open',
  payment_id       VARCHAR(64) UNIQUE,
  attempts         INTEGER NOT NULL DEFAULT 0,
  period_end       TIMESTAMPTZ NOT NULL,
  due_at           TIMESTAMPTZ NOT NULL,
  paid_at          TIMESTAMPTZ
);

CREATE INDEX invoices_payment_id_idx ON invoices (payment_id);
CREATE UNIQUE INDEX invoices_open_per_sub ON invoices (subscription_id)
  WHERE status = 'open';

That last index is worth pausing on. It makes it impossible for a subscription to carry two open invoices at once, which means a renewal job that runs twice by accident cannot charge twice. The database refuses the second insert instead of your code having to remember to check.

The kind column holds first or renewal. It is how the webhook knows whether it is activating a subscription or extending one, without having to infer it from a status it might have already changed.

2. Signing up opens the first invoice

Subscribing does three things in one transaction, and none of them involves Wajub yet.

StepWhat it writes
1A subscription, incomplete, with the customer's phone and the period it would cover
2One invoice, kind = 'first', status = 'open', due today
3Nothing else. The subscription is not active and grants no access

Then you charge that invoice, and hand the customer the authorization_url that comes back. Nothing is active until the money is confirmed, which is the whole reason the subscription starts incomplete.

3. One charge, used by every cycle

This is the only place the Wajub API appears in the entire system. The first payment and every renewal and every dunning retry all go through it.

curl https://api.wajub.com/payments \
  -H "Authorization: sk.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…" \
  -H "Idempotency-Key: sub-84-inv-291-attempt-1" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 9900,
    "currency": "XAF",
    "customer": { "phone": "+237670000000", "email": "amina@example.com" },
    "description": "Premium, October 2026",
    "reference": "sub-84-inv-291",
    "callback": "https://app.example.com/billing/84/return"
  }'

Two details in there are not optional.

The idempotency key carries the attempt number. Without it, a second attempt on the same invoice would return the first attempt's failed payment instead of opening a new one, and the subscription would be stuck forever.

The description is what the customer reads on the prompt on their phone. Premium, October 2026 gets confirmed. A bare merchant name gets declined.

4. The webhook decides, and it reads the invoice

When a payment succeeds, find the invoice by payment_id and let its kind tell you what to do. Never branch on the subscription's current status: the same handler may run twice on a retry, and a status you already changed is not a reliable input.

The event arrives on the endpoint you already built in Accept a payment, so the only new thing here is what happens after the signature is verified.

# The check the function makes. Everything else is your own bookkeeping.
curl https://api.wajub.com/payments/trx_CSUGajfv9xh0XQ5wu2lx \
  -H "Authorization: sk.kZ3qP8mWvL2xR7tB5nY4hC6dF9jS1aG0eU3i…"

A renewal extends from current_period_end, not from today. That is what keeps a customer who pays two days late on the same billing date instead of drifting a little further each month.

A failure needs no call at all. Look the invoice up by payment_id, set it past_due, store data.failure_reason, and let dunning take it from there.

5. Warn before you charge

This step does not exist in a card-based system, and skipping it is the single biggest cause of failed renewals here.

Two days before the period ends, send a message: the amount, the date, and the fact that a prompt is coming. The customer tops up their wallet and answers the prompt when it arrives.

Who to remind, and who to charge
-- Reminder job: warn these, two days out.
SELECT * FROM subscriptions
 WHERE status = 'active'
   AND cancel_at_period_end = false
   AND current_period_end BETWEEN now() + interval '2 days'
                              AND now() + interval '3 days';

-- Renewal job: charge these, today.
SELECT * FROM subscriptions
 WHERE status = 'active'
   AND current_period_end <= date_trunc('day', now()) + interval '1 day';

6. The renewal job

Run it once a day, early. For each subscription the second query returns, it does three things.

StepWhat happensWhat guards it
1If cancel_at_period_end is set, mark the subscription cancelled and move onNothing to charge
2Insert the next invoice, kind = 'renewal', status = 'open'The unique index. A duplicate insert fails, and you skip
3Call chargeInvoiceThe attempt number in the idempotency key

Catching the unique violation in step two is the whole safety mechanism. If the scheduler fires twice, or two workers pick up the same subscription, the second insert fails and the loop moves on without charging anybody twice. Let the database say no rather than checking first and racing anyway.

If chargeInvoice itself throws, mark the invoice past_due and log it. The customer was never prompted, so dunning will pick it up tomorrow like any other failure.

7. Dunning, when a renewal fails

A past_due invoice is a customer who still wants the service and could not pay this morning. Give them a few chances, spaced out, then stop.

Day after due_atWhat the dunning job does
1chargeInvoice again, with a message saying what failed
3chargeInvoice again, warning that access ends soon
5chargeInvoice one last time
7Subscription unpaid, invoice uncollectible, access suspended

Three attempts over a week is a reasonable default. More than that annoys people whose wallet is genuinely empty, and each attempt is a prompt on their phone.

Guard the job with the invoice's own attempts column so that running it twice on the same day does not send two prompts. Every retry goes back through chargeInvoice, which means it gets a fresh idempotency key and opens a genuinely new payment.

8. Cancelling

Two kinds of cancellation, and customers mean different things by the word.

KindWhat you setWhat the customer keepsWhat the renewal job does
At period endcancel_at_period_end = trueAccess until the period they paid for runs outMarks it cancelled on the due date instead of charging
Immediatelystatus = 'cancelled', open invoices voidedNothing, from this momentNever sees it again

At period end should be the default. They paid for the month, so they get the month.

Nothing needs to be cancelled on the Wajub side, because nothing recurring was ever registered there. A subscription is entirely yours, which is the trade you made at the start of this page.

What did you think of this content?