Customers will pay by mobile money, by card, or not at all. The product should show one amount and let the payment method vary, instead of building two checkouts that drift apart.
The amount lives on the invoice
Create the amount once. The STK push and the card session both refer to that invoice and a client reference you generate. When the provider calls back, you look up the reference and mark the same invoice paid. You do not add a second row because a webhook arrived twice.
Callbacks need a public HTTPS address
A provider cannot reach localhost. The callback URL has to be the public site, with TLS, and the handler has to answer quickly and do the slow work after. Verify the payload the way the provider documents, and store the raw event id so a retry is a no-op.
Tell the person what happened
The screen should distinguish “we asked the phone for a PIN,” “the provider has not answered,” and “this invoice is paid.” Those are different states. Collapsing them into a spinner is how support ends up reading database rows to a customer on the phone.
One invoice, two methods
Create the invoice first, with the amount and the currency. The STK push and the card session both store that invoice id and a reference you generated. When either provider calls back, you look up the reference, and if the invoice is already paid you acknowledge the callback and stop. You do not add a second payment because the phone retried.
The customer-facing states are few. Awaiting the PIN, pending at the provider, paid, and failed are enough. Map every vendor phrase onto those four. Support can then say which one it is without reading a vendor dashboard.
Python · payments/invoice.py
def mark_paid(invoice, provider: str, event_id: str) -> bool:
"""Return True when this call is the one that settles the invoice."""
event, created = PaymentEvent.objects.get_or_create(
provider=provider,
event_id=event_id,
defaults={"invoice": invoice},
)
if not created:
return False
if invoice.status == "paid":
return False
invoice.status = "paid"
invoice.save(update_fields=["status", "updated_at"])
return True
What the customer is allowed to see
Awaiting PIN means the STK push was accepted and the phone should be prompting. Pending means you have asked and the provider has not said paid or failed. Paid means the invoice row says paid, not that a spinner finished. Failed means the provider refused or the customer cancelled, and a new attempt needs a new reference so you do not collide with the old event.
The callback URL is the public HTTPS address of this site. A provider cannot call localhost, and a handler that does the slow work before it answers will be retried, which is why the event id is stored first. Acknowledge quickly, then update the invoice. If the update fails, the retry finds the event id and you repair the invoice from the stored payload.
Test with the provider’s sandbox and with a fake that delivers the same event twice. The invoice is paid once. The second delivery returns success and does not send a second receipt. That test is the difference between a checkout and a double charge you discover from a customer.
Keep the provider’s raw status on the event row, and show the customer only the four states. Support can translate. The customer should not have to learn the provider’s vocabulary to know whether to check the phone or to try a card instead.
Send the receipt from the invoice, not from the callback. The callback can arrive twice and the receipt must not. When the mark_paid call returns true, that is the moment to email the receipt and to show the paid state on the next page load.
Before you turn on live payments
- Put the payable amount on one invoice record.
- Use a client reference on both the card session and the STK push.
- Write the three statuses the customer can see.