The short version: it is not the feature list
Credit cards, ATM virtual accounts, convenience-store codes and barcodes, instalments, recurring billing — both gateways have all of it. Which is exactly why comparing feature lists gets you nowhere: you end up picking one more or less at random, and meeting the real differences halfway through the build.
Only four things actually move your schedule and your risk:
- How the encryption and the signature are assembled — decides whether your first week disappears into a parameter error with no visible cause.
- How you get a test environment — decides whether you can start proving the integration this afternoon.
- How you handle the asynchronous result — decides whether you have an incident after launch. This is the only one on the list that costs money.
- What the logistics and back-office settings actually mean — decides whether one misreading costs you an extra server, or locks every real customer out of checkout.
Encryption: AES-128-CBC on one side, AES-256-GCM on the other
ECPay's hosted checkout 2.0 uses AES-128-CBC. PayUni's UPP uses AES-256-GCM. The difference is not only the key length. CBC produces ciphertext and nothing else; GCM also produces a 16-byte authentication tag, and PayUni expects both parts, kept separate, inside a single field.
EncryptInfo is assembled roughly like this:
// Params go in as a URL query string (key=value&key=value)
plain = build_query(params)
cipher, tag = AES_256_GCM(plain, key = HashKey, iv = HashIV)
EncryptInfo = hex( base64(cipher) + ":::" + base64(tag) )
HashInfo = UPPER( SHA256( HashKey + EncryptInfo + HashIV ) )
The two base64 strings are joined by three colons, and then the whole thing is hex-encoded again.Line three is where it goes wrong, and porting from the PHP SDK will not show you why: PHP hands the tag back as a separate return value, so the two parts arrive already separated. Web Crypto — the API shared by Node, Deno and the browser — does not behave that way:
crypto.subtle.encrypt returns one buffer with the tag appended to the ciphertext. The tag is the last 16 bytes. You have to slice it off yourself; what remains in front is the cipher.
buf = await crypto.subtle.encrypt(alg, key, plain)
bytes = new Uint8Array(buf)
cipher = bytes.slice(0, -16) // ciphertext
tag = bytes.slice(-16) // last 16 bytes = auth tag
Skip the slice and what you send is structurally perfect — a hex string, three colons in the right place, a plausible length — and it simply will not decrypt. What comes back usually says no more than that a parameter is invalid. That is the expensive kind of error, because it does not look like an encryption problem at all: you will re-read the parameter table, re-read the docs, suspect a mistyped key, and get to the tag last.
Two more details produce the same symptom — the one that looks like a wrong key and isn't:
HashInfohas to be upper-cased. A lower-case SHA-256 digest is a perfectly valid hex string; it just never matches.- The version field (
Version) belongs in the outer form only — do not put it insideEncryptInfo. The merchant ID (MerID) is the exact opposite: it has to go insideEncryptInfo. Swap those two and the symptom is indistinguishable from a bad key.
ECPay has no tag layer, so there is one less step to get right and a first integration usually goes green sooner. That is a trade, not a verdict: GCM carries its own integrity check and is the newer security model, and the price is one extra step you have to know about — once.
Sandbox: can you start testing today
This one goes straight into your schedule, and almost nobody weighs it while choosing.
ECPay publishes a shared test account. Anyone holding those credentials can run the whole flow in the test environment straight away. In practice that means you can spend an afternoon proving the integration is feasible before you have spoken to a single salesperson, and then decide whether the conversation is worth having.
PayUni's sandbox lives on a separate domain and needs its own registration, and the sandbox and production accounts are entirely separate — two sets of credentials, no crossover. You cannot point production keys at the sandbox, or the other way round.
What that means in practice: if the plan has technical validation happening this week, budget lead time for the application first. It is an administrative queue rather than engineering time, but it eats the same days on the chart. Read the other way, full separation is a feature — a test environment cannot reach for production credentials by accident, and that particular accident moves real money.
You can prove the credentials are right without charging a card
This works on both gateways, and it is now the first thing we do with any new set of credentials.
Early in an integration the most wasteful loop is: assemble the parameters, send, get rejected, and have no idea whether the credentials are wrong, the parameters are wrong, or the feature was never enabled in the back office. Three variables tied together, and the only way through is one at a time.
There is a nearly free way to isolate the credentials question on its own: send a transaction-status query for an order number that does not exist.
- Reply: no such order
- The credentials are correct. The gateway decrypted your request successfully and simply has no record of that order. Key, IV, signature assembly and parameter naming are all right.
- Reply: decryption or verification failed
- The credentials or the assembly are wrong — and you now know the problem is only in the crypto layer, so you can stop suspecting the business parameters.
No card is charged, and there is no test transaction left behind for someone to void later. When the client applies for the credentials themselves and sends them over as a screenshot — which happens more often than not — this takes under three minutes to tell you whether a line was mistyped, or whether the test keys were handed over as production ones.
UNKNOWN must never be treated as a failed payment
If you take one thing from this article, take this one. Everything above costs you at most a day of work. This one costs money.
A card payment is asynchronous. Once the gateway has forwarded the authorisation request, if the bank has not answered within roughly 60 seconds it does not declare the transaction failed — it returns a state that means, in effect, we do not know yet. On PayUni that is UNKNOWN or UNAPPROVED, transaction status code 8. The real outcome arrives later on the Notify callback, or when you query for it yourself around 15 minutes on.
If your code treats that state as a failure, here is the sequence you get:
- The order is marked as payment failed.
- The scheduled job that cancels unpaid orders sweeps it up, cancels the order and returns the stock.
- A few minutes later the gateway delivers a payment successful callback.
- You are now holding a cancelled order, a real settlement, and a stock ledger that no longer reconciles — while on the customer's side the card really was charged.
The worse part is that testing will not find it. Bank responses in a sandbox come back fast, so you never see the state at all. It shows up only in production, when the banking systems are busy — which is usually the busiest day of your year.
On UNKNOWN, hold the order in a pending state. Do not release stock and do not mark it failed. The final state is decided by the Notify callback or a delayed query — never by the timeout.
In code, that is four things:
- The order state machine needs an explicit intermediate state, not just paid and unpaid.
- Send the customer to a page that says the payment is still being confirmed and tells them, in plain words, not to pay again. That copy matters more than you would expect — without it, people put the card through a second time.
- Notify handling has to be idempotent: the same callback delivered three times must leave the same result as one. Deduplicate on the gateway's own transaction reference, not on your order number.
- The auto-cancel job has to exclude the intermediate state, or it will fight the success callback that arrives afterwards.
None of this depends on which gateway you picked — the root cause is that bank authorisation is asynchronous, and that is true everywhere. The only real difference is whether you drew the order state machine before you started writing code.
Five limits that are documented, and that you will still miss
All five are written down in black and white. People miss them not because they did not read the docs, but because they are scattered across different chapters and each one only detonates in one particular situation.
- MerTradeNo (order number)
- The ceiling lands somewhere around 20 to 25 alphanumeric characters depending on the endpoint, and it must not repeat within ten minutes. Ordinary web order numbers carry hyphens and full dates, so they are already too long to send as-is. Generate a separate short code for the gateway and keep a lookup table back to your own order; when the customer retries payment, issue a new short code.
- UsrMail (customer email)
- An empty value is not allowed and comes back as a format error. Include the field only when you actually have an address; otherwise leave it out of the payload entirely. Build every optional field so that it is added only when it has a value, and you eliminate a whole class of format errors at once.
- ExpireDate (payment deadline)
- Convenience-store payment caps at 7 days; ATM virtual accounts go up to 180. And if you set more than 7 days, the convenience-store option silently disappears from the hosted checkout page — no error, it is just not there. When someone reports that there is no convenience-store option, check this field first.
- Notify / Return URL
- Ports 80 and 443 only. Tunnel your callbacks through any other port in development and the notifications never arrive — with no error of any kind, just silence.
- Instalment terms
- The terms you send have to be the ones actually enabled on your gateway account; send one that is not enabled and it fails. Rates also step up with the term: paying in full is around 2.8%, and by 12 instalments you are near 6.5% (your signed contract is the authority on the real numbers). Whether to offer the long terms is a margin decision, not a technical one — settle it with whoever owns pricing, then hard-code the menu.
Logistics: we stood up a server we did not need
This section is our own misjudgement. It is written down so you do not have to make the same trip.
Gateway logistics generally comes in two routes:
- The back-end API — called directly by your server to create shipments, fetch pickup-store data and reprint labels.
- Logistics parameters on the hosted checkout page — the shipping requirement travels with the payment request, the customer picks a pickup store and fills in the recipient on the gateway's own page, and the gateway creates the shipment.
The back-end API has one hard constraint: it only accepts source IPs you registered in advance. Most sites now run serverless, where the outbound IP floats and can change at any moment. So we concluded: stand up a machine with a static IP, relay the requests through it. And we did — HTTPS certificate and all.
Only afterwards did we work out that the capability we needed was available on the second route. On the hosted checkout page the whole exchange is a form submission from the browser, so the source IP is the customer's own. The source-IP allowlist never enters into it, and neither does the relay.
Before building infrastructure to work around a constraint, check whether the same thing can be done through the browser. An extra machine is one more thing to monitor, one more certificate to renew, and one more thing that can fall over at three in the morning.
There are genuine cases for the back-end API — batch shipment creation in a back office, support staff reprinting labels, any logistics action a customer did not trigger. None of those have a browser to borrow. But if all you need is for the customer to pick a pickup store at checkout, you do not need that machine.
The IP transaction restriction setting is not an allowlist
Following on from the last section. Somewhere in the gateway back office you will find a setting called IP transaction restriction. If you happen to be wrestling with static IPs at the time, it reads very naturally as an API source allowlist — put your server's IP in and you can call the API.
It is not. It means only IPs on this list may complete a transaction. It is an anti-fraud control on where the customer is paying from.
So if you enter your own server's IP and switch it on, the result is that every real customer in the world can no longer pay, and only requests leaving that one address get through. It is also exactly the kind of mistake testing will not catch — your own runs pass, everything breaks the moment customers arrive, and you will go looking in the code first.
The general rule: when you are not certain what a back-office field means, ask support rather than inferring it from the name and switching it on. Most payment back-office settings take effect immediately and apply to every real transaction. That is not a place to learn by experiment.
The runtime bites too: text/html rewritten to text/plain
One of the most common ways to wire up a gateway is to have the backend build a self-submitting HTML form and return it straight to the browser, which then POSTs itself to the payment page. On an ordinary Node server this is completely fine, and it is how most of the sample code is written.
When we moved that logic to an edge runtime (Supabase Edge Functions), we found it rewrites the response Content-Type from text/html to text/plain. The browser then renders the whole form as plain text — the user gets a page of source code instead of a payment page.
This is not the gateway's problem and it is not a bug in your code; it is the runtime's behaviour. The fix is simple:
Return JSON from the backend — the payment URL plus every field — and let the frontend build the form and submit it.
That shape has two side benefits: the frontend can show a redirecting state before it submits, so nobody thinks the page has hung; and the same endpoint now works on any runtime.
The more general lesson: a payment problem is not always a payment problem. Serialisation, time zones, Content-Type, character encoding, runtime defaults — every one of them is good at disguising itself as an integration error.
The last trap has nothing to do with payments, and cost us the most
The submit handler was wrapped in React's useCallback, and the dependency array was missing a few pieces of state.
The symptom: home-delivery orders reached the gateway with an empty recipient address, and the convenience-store pickup flag never arrived. It looks exactly like a broken integration — mismatched parameters, missing fields — and we spent a long time looking in that direction. What was actually happening is that the closure inside the handler was holding values from the previous render: the customer had picked a store and typed an address, and what went out was the state from before they did.
If a handler reads a piece of state, that state must appear in the dependency array. Change what a handler touches and you update the dependencies in the same commit.
The more practical defence: log the final payload once, in full, immediately before it goes out (with the sensitive fields stripped), then compare what is on screen against what was sent. That single step turns this whole class of bug from a two-day hunt into a five-minute one.
So which one should you pick
Do not ask which one is better — both are working, ordinary providers, both have the features, and both document them reasonably well. Ask these three questions instead:
- How tight is your schedule? If technical validation has to happen this week, a public test account gets you moving sooner. If the evaluation window was always going to be two or three weeks, one more sandbox application changes nothing.
- What are you running on? Serverless and edge runtimes make three things matter: how your crypto library behaves (the GCM tag), what the runtime does to the response Content-Type, and whether you need any capability that requires a fixed outbound IP. Those three shape your implementation far more than how pleasant the API looks.
- Which combination of features do you actually need? Logistics, instalments, recurring billing, collection and disbursement — each one differs between the two in how you enable it, how long approval takes and what it costs, and most of that is a commercial condition rather than a technical one. Get the answers from the sales side before you write any code.
What decides whether a project runs smoothly was never a box on the feature list. It is all of the above — none of which is printed in red in the spec.
We have integrated both, and both are live and running. If you get stuck evaluating or mid-integration, call and ask.
Talk about a projectBack to notes