While building the payment flow for a single-vendor e-commerce portfolio project, I wrote a Midtrans webhook handler that looked correct on the first pass. It parsed the payload, checked the signature, updated the order status, and adjusted stock. It would have passed tests for the happy path, and I initially considered it done.
Then I went back through Midtrans' official documentation on transaction statuses and fraud detection, and found a bug that would have silently failed transactions paid through QRIS, bank transfer, or other non-card methods — which, for most Indonesian e-commerce stores, make up the majority of traffic.
That bug turned out to be the first of several problems that only surface once you take webhooks seriously: signature validation edge cases, why webhooks retry and what that quietly breaks, a real reversal case most tutorials never mention, and a subtle bug in reservation-release logic that took me three attempts to get right. This post walks through all of them.
Trusting a Public Endpoint
The Midtrans webhook endpoint has no authentication in the usual sense — it's AllowAny, reachable by anyone on the internet. That's by design: Midtrans needs to be able to POST to it without a login flow. But it means the handler itself has to verify that the request actually came from Midtrans, not from someone who found the URL and decided to send a fake "payment successful" notification.
Midtrans solves this with a signature: signature_key is generated by appending order_id, status_code, gross_amount, and ServerKey into a string, then hashing it with SHA-512. The server key is a shared secret only Midtrans and the merchant know, so nobody else can build this signature hash.
def validate_signature(self, payload):
try:
self.payload = json.loads(payload)
except json.JSONDecodeError:
raise InvalidMidtransPayload("Invalid JSON payload")
order_id = str(self.payload.get("order_id"))
status = str(self.payload.get("status_code"))
gross_amount = str(self.payload.get("gross_amount"))
signature = self.payload.get("signature_key")
raw = f"{order_id}{status}{gross_amount}{settings.MIDTRANS_SERVER_KEY}"
expected_signature = hashlib.sha512(raw.encode()).hexdigest()
if signature != expected_signature:
raise InvalidMidtransSignature("Invalid Midtrans signature")
return self.payload
If the JSON body is malformed, or the signature doesn't match, the request is rejected before touching the database at all — the order lookup and status update only happen after this check passes.
Bug #1: The fraud_status Trap
My first version of the status logic looked like this:
if transaction_status in ["settlement", "capture"]:
if fraud_status == "accept":
self.order.payment_status = "paid"
This passed every test I initially wrote, because those tests used card-like payloads with fraud_status: "accept". It looked done.
The problem surfaced once I checked how Midtrans' Fraud Detection System actually applies. Whitelisting for FDS can only be done for card transactions and does not apply to other payment methods which is a strong signal that FDS, and by extension fraud_status, is a concept tied specifically to card payments. QRIS, bank transfer, and other non-card methods in this store's enabled_payments list aren't evaluated by it the same way. In practice, that means fraud_status can arrive as None for those methods — and my original check silently rejected every one of them, because None == "accept" is False.
The fix:
if transaction_status in ["settlement", "capture"]:
new_status = "paid" if fraud_status in (None, "accept") else "failed"
Nothing crashed. No error was logged. Orders paid through the store's most common payment methods would have just... never turned into paid, silently sitting in pending forever. That's the kind of bug that doesn't show up until someone complains their payment "went through" but the order never updated — which is a much worse way to find out than reading the docs.
Idempotency — Why Webhooks Retry, and Why That Breaks Naive Code
Webhooks aren't a one-shot delivery. In extremely rare cases, Midtrans may send multiple notifications for the same transaction event, and it should not cause duplicate entries at the merchant's end. If your server is slow to respond, times out, or returns anything other than a clean success, Midtrans will try again.
That single fact breaks a lot of naive webhook code. If reduce_stock() runs every time a "paid" notification arrives, without checking whether it already ran, a retried webhook will deduct stock twice for the same order — once because the customer actually bought two items, and once again because Midtrans wasn't sure the first notification got through.
The fix is a guard based on state, not on trusting each webhook to only arrive once:
def reduce_stock(self):
try:
if not self.order.reduced_stock:
reduce_product_stock(self.order.items.all())
self.order.reduced_stock = True
self.order.save(update_fields=["reduced_stock"])
except Exception:
logger_error.exception(
"Failed to reserve stock",
extra={"event_type": "transaction", "order_id": self.order.order_id},
)
raise
reduced_stock is a boolean on the order itself, not a separate idempotency-key table. The first webhook flips it to True and deducts stock. Every subsequent webhook for the same order — retry or otherwise — sees True and does nothing. Midtrans' own recommendation is to use order_id as the key to track entries and implement notifications in an idempotent way, and this is the simplest version of that: reuse a field that already has to exist, instead of adding new infrastructure just to deduplicate.
The exception handling matters here too. If reduce_product_stock() fails — say, a database hiccup — the exception is logged and re-raised, not swallowed. That makes the view return a 500, which tells Midtrans to retry. The retry mechanism Midtrans already provides becomes the recovery mechanism, as long as the operation stays idempotent.
The Reversal Case Nobody Talks About
Most webhook tutorials treat settlement as a terminal state: the money is in, the order is paid, done. Midtrans' own documentation says otherwise for a specific set of payment methods: for Permata Bank Transfer, Mandiri Bill Payment, and Indomaret, in rare cases settlement can later change into deny — known as Reversal — usually within 1-5 minutes, caused by the payment provider's network. When that happens, the transaction fund is reversed back to the customer, and no fund is received on the merchant side.
That means payment_status can't just move
forward through pending → paid and stop there. It needs a rule for when paid is allowed to become failed again — and, just as importantly, a rule for when it isn't.
def change_payment_status_order(self):
transaction_status = self.payload.get("transaction_status")
fraud_status = self.payload.get("fraud_status")
old_status = self.order.payment_status
if transaction_status in ["settlement", "capture"]:
new_status = "paid" if fraud_status in (None, "accept") else "failed"
elif transaction_status in ["deny", "cancel", "expire"]:
new_status = "failed"
else:
self.old_status = old_status
self.new_status = old_status
return old_status == "paid"
if old_status == "failed" and new_status == "paid":
logger_error.warning(
"Order sudah failed, transaksi mencoba jadi paid - diabaikan",
extra={
"event_type": "transaction",
"order_id": self.order.order_id,
"transaction_status": transaction_status,
},
)
self.old_status = old_status
self.new_status = old_status
return False
if old_status != new_status:
self.order.payment_status = new_status
self.order.save(update_fields=["payment_status"])
self.old_status = old_status
self.new_status = new_status
return new_status == "paid"
The rule ends up asymmetric on purpose: paid → failed is allowed (that's the reversal), but failed → paid is blocked. Midtrans never documents a transaction coming back to life after being denied, cancelled, or expired — so if that transition is ever requested, it's
treated as a stale or out-of-order notification, logged, and ignored rather than trusted.
Two Kinds of Stock
Before going into the explanation, it's worth being explicit about something the code relies on but never says out loud: this store tracks two separate stock numbers per product.
-
stock— the actual, physical count of items available. -
reserved_stock— items temporarily held during checkout, before payment is confirmed one way or another.
A customer starting checkout doesn't reduce stock yet — it bumps reserved_stock, so the same item can't be oversold to someone else while this customer is still deciding whether to pay. stock itself only moves once the order's fate is actually known.
That distinction matters because a failed order can fail in two very different ways, and each one needs a different cleanup:
-
pending→paid—reduce_stock()runs, reducing bothstockandreserved_stock. -
pending→failed—release_reservation()runs, reducing onlyreserved_stock. stock is untouched, since it was never deducted in the first place. -
paid→failed(reversal) —reverse_stock()runs, increasing stock back.reserved_stockis untouched, since it was already cleared when the order first became paid.
The code backs this up directly. reduce_product_stock(), called when an order becomes paid, touches both fields:
product.stock -= item.qty
product.reserved_stock -= item.qty
product.save(update_fields=["stock", "reserved_stock"])
restore_product_stock(), called on reversal, touches only stock:
product.stock += item.qty
product.save(update_fields=["stock"])
And release_reservation(), called when an order fails before ever reaching paid, touches only reserved_stock:
product.reserved_stock -= item.qty
product.save(update_fields=["reserved_stock"])
Without release_reservation(), an abandoned checkout — someone who starts paying and never finishes — would leave reserved_stock inflated forever. Nothing physically sold, but the store would slowly look more out of stock than it actually is.
Where it gets tricky
release_reservation() needs to run only when an order fails for the first time — not on a reversal, and not on a duplicate webhook. A single guard based on reduced_stock isn't enough, because reverse_stock() (called just before it) also modifies that same flag as a side effect, so it stops being a reliable signal for what's happening in this webhook.
The guard that actually holds checks both the status before and after this specific request:
webhook_midtrans.reverse_stock()
if webhook_midtrans.new_status == "failed" and webhook_midtrans.old_status not in ("paid", "failed"):
webhook_midtrans.release_reservation()
This narrows the trigger down to exactly one case: an order moving into failed for the first time, ruling out both reversals and duplicate notifications.
Testing Strategy — Three Layers, Three Purposes
Once the logic settled, the next question was how to test it without either missing real bugs or drowning in redundant tests. I ended up with three layers, each proving something the others can't.
Unit tests — WebhookMidtrans methods tested in isolation, with MagicMock() standing in for the order, products, and database. These cover the decision logic itself: status transitions for every combination of transaction_status and fraud_status, the idempotency guards on reduce_stock() and reverse_stock(), and signature validation against malformed or mismatched payloads. No database, no HTTP — just whether the logic produces the right output for a given input.
def test_paid_to_failed_on_reversal(self):
payload = {"transaction_status": "deny", "fraud_status": None}
webhook = self._build_webhook(payload, current_status="paid")
result = webhook.change_payment_status_order()
self.assertFalse(result)
self.assertEqual(webhook.order.payment_status, "failed")
self.assertEqual(webhook.old_status, "paid")
Integration tests with mocks — MidtransWebhookView tested through the actual HTTP layer, but with WebhookMidtrans mocked entirely. These don't re-test the logic above; they test wiring — that the view calls reduce_stock() when is_paid is True and reverse_stock() otherwise, that each exception type from get_order() maps to the right status code, and that a failure in reduce_stock() or reverse_stock() returns a 500 rather than a misleading success.
End-to-end tests (TransactionTestCase, a real database) — the view, WebhookMidtrans, and the actual models, no mocking. This is the only layer that can prove things the other two can't: that transaction.atomic() really rolls back every write in a loop when one item fails partway through, that model relationships and field names actually line up, and that a real database failure doesn't leave stock partially updated.
def test_reduce_stock_failure_rolls_back_earlier_item_in_same_loop(self):
self.product_b.stock = 1 # not enough for the requested qty
self.product_b.save(update_fields=["stock"])
order = self._make_order(payment_status="pending")
body = self._signed_payload(order.order_id, "settlement", fraud_status=None)
response = self._post(body)
self.assertEqual(response.status_code, 500)
self.product_a.refresh_from_db()
self.assertEqual(self.product_a.stock, 10) # rolled back, not silently deducted
That last test is the reason all three layers exist. A mock can tell you reduce_stock() was called. It can't tell you what happens to a second product in the same order when the third one fails partway through a loop, inside a transaction, against a real database. Only running it for real answers that.
Closing
None of these bugs were dramatic. Nothing crashed, no stack trace pointed at the problem, no error log screamed for attention. That's exactly what makes payment code different from most other backend work — a silent failure here doesn't mean a feature is broken, it means money moves and your records don't agree with it, or stock quietly drifts out of sync with what's actually on the shelf. The cost of getting it wrong shows up somewhere else, later, as a support ticket or a reconciliation headache.
Working through this taught me to treat "it passed the happy path" as the start of the review, not the end of it — and to actually read the payment provider's documentation for the cases it explicitly calls out as rare, instead of assuming those cases are rare enough to ignore.
The full implementation, including all three layers of tests, is available on success GitHub. If you're working on something similar or spot something I missed, I'd genuinely like to hear about it — reach out on LinkedIn or drop a comment below.
Top comments (0)