Payment code has a different risk profile from normal CRUD. A payment provider can timeout, reject a request, retry a webhook, send an event late, or receive forged traffic from someone pretending to be the provider. The backend has to assume the network is unreliable and the public webhook endpoint is hostile.

This project integrates Chapa in two steps: initialize payment and confirm payment.

Initialization is authenticated. The user sends an order_id, and the serializer validates that the order exists, belongs to the current user, contains order items, and is still pending. The view generates a unique tx_ref, calls Chapa’s initialize endpoint, creates a local Payment row with pending status, and returns the hosted checkout URL.

That local Payment row is important. It gives the app a durable link between the local order and the provider transaction reference. Without it, the webhook would have no trusted local object to update.

Webhooks need more than a status field

The webhook endpoint is public because Chapa has to call it. Public does not mean trusted. The code checks Chapa signature headers using HMAC comparison before parsing and acting on the payload.

Even after the signature check, the view does not blindly trust "status": "success" from the webhook body. It calls Chapa’s verify endpoint using the transaction reference and treats the provider verification response as the source of truth. Only then does it update the local Payment and related Order to success.

That is the main payment lesson: webhook bodies are notifications, not final truth. They tell your system to go verify.

The local update happens inside transaction.atomic(), so payment and order state move together. A successful payment should not leave the order pending, and a successful order should not leave the payment pending.

Error handling as API design

Payment initialization distinguishes several failure modes. A Chapa HTTP error returns a 400 with provider rejection details. A timeout returns 504. A network failure returns 503. Those status codes help the frontend and operator understand whether the user should retry, fix input, or wait for the provider.

That is better than catching every requests exception and returning “payment failed.” Payments are already stressful for users. Precise errors reduce confusion.

The webhook takes a different approach. If provider verification fails after receiving a plausible webhook, the endpoint acknowledges with 200 and logs the issue instead of repeatedly retrying an event it cannot safely process. That is a pragmatic choice, but it has a tradeoff: operators need logs or monitoring to investigate verification failures.

What I would harden next

The next production step would be idempotency. Webhooks can be delivered more than once, and payment initialization can be retried by impatient users. The unique transaction reference helps, but the state transition code should explicitly tolerate duplicate success notifications.

I would also add unpaid-order expiry. In the current checkout design, stock is decremented before payment succeeds. That reserves inventory for pending orders, which is useful, but abandoned payments need a policy to restore stock or cancel the order.

The broader lesson is that payment integration is not just “call an API.” It is a state machine spread across your database, the provider, a redirect flow, and an asynchronous webhook. Django’s transactions, serializers, permissions, and models gave me the structure to make that state explicit.