Common Login Portal
Bank Muscat Error 10001: a GET that ate a POST
Ashish Bhagat · 13 Aug 2026
The bank's payment page returned one line:
Error 10001
No description, no field-level detail — just a code, from Bank Muscat's gateway, the moment a user's browser landed on the bank-hosted payment page as part of Common Login Portal's registration flow. CLP is the identity and single-sign-on layer for Oman's National Port Community System, and organization registration on it isn't free: it ends with an invoice and a real payment, processed through a Kale-operated gateway microservice sitting in front of Bank Muscat. Error 10001 meant the bank had received a request it wasn't willing to process, and it wasn't going to say more than that.
Error 10001
Bank-hosted payment pages are a deliberately narrow integration surface. The bank owns the page the user actually enters card or authentication details on — CLP never sees that data — which means everything on CLP's side of the boundary is about correctly handing off to that page and correctly receiving its result afterward. There isn't much room for CLP's own logic to go wrong once the user is looking at Bank Muscat's page; the entire surface area for a bug on our side is the handoff itself, in both directions. That's exactly where this one lived.
The obvious first suspect, for anything a bank gateway rejects outright, is the encrypted payload itself. The integration builds an AES-128-CBC encrypted payment request and hands it to the bank — so the natural early hypothesis was some mismatch on the encryption side: wrong key reference, a padding issue, a field encoded in the wrong order. That's where I looked first, because that's where a silent rejection from a payment gateway usually points.
The payload wasn't the problem. The request wasn't malformed. It was incomplete — the bank was never receiving the encrypted body at all.
The request that never had a body
The payment flow, at that point, redirected the user's browser from CLP straight to the bank's payment URL once the encrypted request had been prepared. A direct browser navigation to a URL — whether via a server-side redirect response or the frontend setting window.location — is always a GET. A GET request doesn't carry a body the way a form submission does. There's no mechanism for "navigate to this URL, and also silently attach this payload" outside of query parameters, and the encrypted payment payload was never meant to travel as a query string.
So the browser arrived at Bank Muscat's endpoint exactly as instructed, and exactly empty-handed. The bank's gateway saw a GET with no payment payload, had nothing to decrypt or validate, and answered with Error 10001. From the bank's side, that's not really a bug report — it's a gateway correctly rejecting a request that doesn't contain what it's supposed to contain. The bug was entirely on the sending side: the payload existed, but it never left the client, because a plain navigation has nowhere to put it.
There's a version of this bug's history that's worth naming explicitly, even without a confirmed line of code to point at: HTTP redirects and POST bodies have a genuinely awkward relationship. A server responding to a POST with certain redirect status codes has historically left browsers free to replay the follow-up request as a GET, dropping whatever body the original request carried — behavior that's been a long-running source of confusion across the web platform, tightened up in newer status codes but never fully erased as a footgun for anyone redirecting after a form-like submission. Whether the specific path here went through a redirect response or a direct client-side navigation, the underlying failure is the same shape: something in the chain treated "send the user to this URL" as equivalent to "send the user's data to this URL," and only one of those two things is actually true of a GET.
Serving a form that submits itself
The fix doesn't touch the encryption, the keys, or anything about how the payload is built — it changes how the browser gets that payload to the bank in the first place. Instead of redirecting straight to Bank Muscat's URL, the backend renders a minimal HTML page: a form pointed at the bank's endpoint, with the encrypted fields as hidden inputs, that submits itself the instant the page loads.
Reconstructed for this post, not the literal production markup:
<!-- Illustrative — shows the approach, not the production template -->
<body onload="document.forms[0].submit()">
<form method="POST" action="https://payments.bankmuscat.example/gateway">
<input type="hidden" name="encryptedRequest" value="{{encryptedPayload}}" />
<input type="hidden" name="merchantId" value="{{merchantId}}" />
</form>
</body>
The user sees a blank page for a fraction of a second and then the real Bank Muscat payment page, same as before. The difference is invisible to them and load-bearing for the integration: the browser's actual request to the bank is now a genuine POST, built by the form, carrying the encrypted payload as form fields instead of nothing at all. Nothing about the crypto changed. What changed is that the browser was finally given something capable of carrying a body to a bank that only accepts requests with one.
It's worth being clear about why appending the payload as a query string instead was never a real option, even setting the encoding problem aside. An AES-128-CBC encrypted payment request is exactly the kind of thing you don't want sitting in a URL — proxies, load balancers, and web servers routinely log full request URLs as a matter of course, and a query string is server access logs' business in a way a POST body generally isn't. Putting an encrypted payment payload where it would get written to a log file on every hop between the browser and the bank isn't a workaround for the GET problem, it's a second problem stacked on top of the first one.
What I'd do differently
Every external dependency in CLP — the labour ministry API, the commercial registry API, this payment gateway — sat behind a mock adapter specifically so QA and UAT could run without the live government and bank systems, which were frequently unavailable. That pattern is genuinely good, and it caught a lot. It didn't catch this, because a mock payment adapter validates that your backend builds the right payload and handles the right response shape — it doesn't reproduce browser-level request mechanics like GET versus POST, because a mock, by definition, isn't a browser navigating to a real third-party's real endpoint. That gap only shows up against the actual bank.
If I were building this again, I'd treat "how does the browser physically deliver this to the third party" as its own explicit test, separate from whether the mock and real adapters return matching shapes — because the mock will happily agree with a design that a real browser can't execute.
More from this series:
Three clocks racing: a payment reconciliation bug · Mock and real adapters behind every port