A payment endpoint is not a recurring billing API
A provider can give you an endpoint that starts an M-PESA, Airtel Money or MTN MoMo payment. That is payment infrastructure. A recurring billing API needs another layer of meaning:
- what is due, and for which period;
- which attempts tried to pay it;
- whether the provider's result is final;
- whether another attempt is safe;
- what happens after payment;
- how finance can reconcile the result.
Without those concepts, an integration can charge, but it is not yet a complete recurring-billing contract. The examples below are illustrative, not a specific product's API.
1. Make the obligation a first-class resource
The core resource represents the commercial amount due, independently of any provider:
{
"id": "obl_01J9K8",
"customer_id": "cus_1842",
"merchant_id": "mer_201",
"currency": "KES",
"amount": 350000,
"billing_period": "2026-11",
"due_at": "2026-11-05T00:00:00+03:00",
"status": "DUE"
}
Amounts are in the smallest currency unit (350000 is KES 3,500.00). The field names matter less than the principle: the obligation exists before any wallet is chosen.
2. Create a new attempt resource for every real attempt
One obligation can have several attempts. A request to start one:
POST /v1/obligations/obl_01J9K8/attempts
Idempotency-Key: 01J9K9-req-1
{
"payment_method": "MOBILE_MONEY",
"wallet": "MPESA"
}
And the response:
{
"id": "att_01J9KA",
"obligation_id": "obl_01J9K8",
"wallet": "MPESA",
"provider": "provider_a",
"status": "AWAITING_CUSTOMER",
"provider_reference": "ws_CO_0611202614070000",
"retry_allowed": false
}
Never overwrite attempt 1 when the customer retries. An unchanging attempt history is what lets you handle late callbacks, investigate duplicate payments, support customers, reconcile and migrate providers.
3. Use idempotency at every write
Recurring payment systems see repeated requests all the time: a client retries after a timeout, a scheduler runs twice, a callback is replayed, a worker crashes after calling the provider but before saving the result. Make these operations idempotent:
- creating obligations;
- creating attempts;
- starting the provider payment, where the provider supports it;
- processing callbacks;
- closing invoices;
- sending downstream service events.
The rule: the same logical action creates one commercial effect. For example, a database constraint that prevents duplicate obligations for one billing period:
UNIQUE (merchant_id, external_billing_key, billing_period)
And an idempotency key for attempt creation built from the merchant, the obligation and a client request ID. A genuine retry gets a new request ID; a repeated delivery of the same request reuses it.
4. Normalize provider state without deleting the raw evidence
Providers use different status names and result codes. Your API should expose one stable state model:
INITIATED
AWAITING_CUSTOMER
PENDING
SUCCEEDED
FAILED
EXPIRED
UNKNOWN
Keep the raw provider data alongside it:
{
"status": "FAILED",
"provider_status": "1032",
"provider_message": "Request cancelled by user",
"provider_reference": "ws_CO_0611202614070000"
}
Normalized state lets the merchant build one workflow; raw data keeps support and reconciliation defensible.
5. Treat asynchronous completion as normal
Mobile-money payments usually complete outside the request that started them. With Safaricom's M-PESA Express (STK Push), Daraja returns a CheckoutRequestID immediately and sends the final result to your callback URL later. If that callback is late, the M-PESA Express Query API returns the status of the request using the CheckoutRequestID. For completed transactions, the Transaction Status API looks a transaction up by receipt number and is documented as a secondary reconciliation mechanism when callbacks are not received.
A recurring API therefore needs:
- callback endpoints;
- a way to authenticate or validate provider events;
- deduplication;
- handling for events that arrive out of order;
- a status-check path for late results;
- an UNKNOWN state;
- retry blocking while an earlier attempt is unresolved.
Creating an attempt should not block until the final commercial outcome is known.
6. Make UNKNOWN a first-class state
UNKNOWN means "we do not have enough evidence to call this succeeded or failed," and it should change how the API behaves:
{
"status": "UNKNOWN",
"retry_allowed": false,
"next_action": "VERIFY_PROVIDER_STATUS"
}
That is safer than collapsing uncertainty into FAILED. A later status check moves the attempt to SUCCEEDED or FAILED.
7. Close the obligation exactly once
A successful attempt should settle the obligation atomically, so that two successes can never both mark it paid:
def apply_success(obligation_id, attempt_id):
with db.transaction():
obligation = lock_obligation(obligation_id)
if obligation.status == "PAID":
record_duplicate_success(attempt_id)
return
mark_attempt_succeeded(attempt_id)
mark_obligation_paid(
obligation_id=obligation_id,
satisfying_attempt_id=attempt_id,
)
The exact code depends on your stack. The rule is what matters: one obligation has one normal satisfying payment, and any later success becomes an exception, not a second "paid."
8. Expose retry eligibility separately from payment status
FAILED does not automatically mean "retry now." Whether to retry can depend on the failure reason, your retry limit, the grace period, the time since the last attempt and your wallet fallback policy:
{
"status": "FAILED",
"failure_category": "CUSTOMER_CANCELLED",
"retry_allowed": true,
"next_action": "CUSTOMER_INITIATED_RETRY"
}
Failure-reason rules belong in the recovery system, not hard-coded separately by every merchant client.
9. Support payments made elsewhere
Real merchants receive money outside the preferred API path. A trusted workflow should be able to record that the obligation was paid another way:
POST /v1/obligations/obl_01J9K8/external-satisfaction
{
"method": "BANK_TRANSFER",
"reference": "BANK-REF-2841",
"amount": 350000,
"verified_at": "2026-11-05T14:10:00+03:00"
}
This stops the recovery engine from chasing a customer who has already paid.
10. Give reconciliation its own resource
Payment success and reconciliation should not share one status:
{
"obligation_id": "obl_01J9K8",
"satisfying_attempt_id": "att_01J9KA",
"payment_status": "SUCCEEDED",
"reconciliation_status": "MATCHED",
"settlement_status": "PENDING",
"provider_reference": "ws_CO_0611202614070000",
"amount": 350000,
"currency": "KES"
}
That lets the customer's account close while finance continues its own work.
11. Scope payment capability by market
For a regional merchant, avoid a single flag such as "mpesa_enabled: true". Scope capability to the market and provider:
{
"market": "KE",
"currency": "KES",
"wallet": "MPESA",
"provider": "provider_a",
"merchant_account": "acct_201",
"status": "ENABLED"
}
The same wallet brand in another country may run on a different operator or provider arrangement.
12. Emit billing events, not raw provider callbacks
The merchant should subscribe to stable billing events:
obligation.due
payment.attempt_updated
obligation.paid
obligation.overdue
reconciliation.matched
For example:
{
"id": "evt_01J9",
"type": "obligation.paid",
"created_at": "2026-11-05T14:07:31+03:00",
"data": {
"obligation_id": "obl_01J9K8",
"satisfying_attempt_id": "att_01J9KA"
}
}
The merchant's service system should never need to understand a PSP-specific callback format.
13. Design the API around merchant guarantees
A strong recurring API makes these rules hard to break:
- one billing period does not accidentally create duplicate obligations;
- an unresolved attempt blocks unsafe retries;
- a paid obligation stops recovery;
- one successful attempt settles the obligation once;
- raw provider evidence stays traceable;
- payments made elsewhere can be recorded honestly;
- changing provider does not change the merchant's billing contract.
That is the difference between an API that can start mobile-money payments and one that can run recurring billing across them.



