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 expect | What actually happens |
|---|---|
| Wajub charges the customer each month | Your job creates a payment, the customer confirms it |
| A failed renewal retries itself | You decide when and how often to retry |
| The customer never thinks about it | They 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.
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.
| Step | What it writes |
|---|---|
| 1 | A subscription, incomplete, with the customer's phone and the period it would cover |
| 2 | One invoice, kind = 'first', status = 'open', due today |
| 3 | Nothing 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.
-- 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.
| Step | What happens | What guards it |
|---|---|---|
| 1 | If cancel_at_period_end is set, mark the subscription cancelled and move on | Nothing to charge |
| 2 | Insert the next invoice, kind = 'renewal', status = 'open' | The unique index. A duplicate insert fails, and you skip |
| 3 | Call chargeInvoice | The 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_at | What the dunning job does |
|---|---|
| 1 | chargeInvoice again, with a message saying what failed |
| 3 | chargeInvoice again, warning that access ends soon |
| 5 | chargeInvoice one last time |
| 7 | Subscription 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.
| Kind | What you set | What the customer keeps | What the renewal job does |
|---|---|---|---|
| At period end | cancel_at_period_end = true | Access until the period they paid for runs out | Marks it cancelled on the due date instead of charging |
| Immediately | status = 'cancelled', open invoices voided | Nothing, from this moment | Never 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.
Every retry needs its own idempotency key
One invoice, several attempts, a different key each time. Reusing a key returns the previous payment
with its previous outcome, which turns a recoverable failure into a subscription that can never be
revived. The -attempt-{n} suffix in chargeInvoice is the whole mechanism.
Related pages
- Accept a paymentThe order lifecycle every charge here is an instance of.
- Recurring invoicesThe lighter alternative, and exactly where it stands today.
- Laravel recurring billingThe same problem, written out in one Laravel application.
- IdempotencyKeys that deduplicate, and keys that lock you out of a retry.
- Mobile Money checkoutWhy a renewal is a prompt on a handset, and what that costs.