Context
Closing a tab, or trusting a callback body, is not settlement. M-Pesa Express looks simple from checkout. The failure modes are missing callbacks, duplicate receipts, and amounts that do not match.
Daraja M-Pesa for WooCommerce is the PHP boundary because that is where WooCommerce lives. The invariant is the same as the Python and .NET labs: money moves after evidence matches.
Decision
STK Push starts only after an immutable payment attempt exists. The WooCommerce order stays unpaid until a callback, or a correlated status query, supplies the exact amount and a unique receipt.
attempt row → STK Push → callback and/or STK Query → exact-amount match → paid
The storefront never calls Safaricom. Polling on the order page is read-only. If the customer leaves, server-side reconciliation continues. STK Query success without an exact callback amount becomes succeeded_unverified; it does not mark the order paid. Manual verification is capability-protected, nonce-protected, and requires a fresh Daraja query plus an operator reason.
What I rejected
Marking the order paid from the browser return URL, and treating any Daraja “success” as settlement without the amount. Both create paid orders with no matching receipt.
Failure scenario
The callback never arrives. Reconciliation runs STK Query. Amount mismatch stays unpaid. Duplicate callbacks with the same receipt are idempotent. Tokens stay out of logs; phone numbers are a keyed hash on the attempt.
Evidence
WordPress/WooCommerce plugin, callback correlation tests, and documented timeout plus manual verification. Independent work, not affiliated with Safaricom or WooCommerce. Pre-release; use sandbox credentials until callbacks, reconciliation, and refunds are rehearsed.
Inspect: Repository · Run locally