In one week I counted nine separate people asking the same question in public, in more or less these words: what should we use instead of our current payment gateway? One had a client demanding the change. One had been asked for the identity documents of every director. One had been told his account needed upgrading because his site had been classified as a marketplace. Two had simply been rejected at signup with no reason given. One had been suspended before he had taken a single payment.
Every one of those threads filled up with provider names within the hour. Not one of them contained the question that decides the cost, which is not which provider but what breaks when we move.
The answer is unintuitive and it is the reason I wrote this down: almost none of the work is at the provider. The provider is a signup form and a set of credentials. The work is in the code you already wrote, and in the assumptions that code made without ever announcing them.
Webhook verification is a different security model, not different syntax
This is the one people assume is a rename. It is not.
Every virtual account number you have ever issued stops being yours
The prefix belongs to the provider, not to you.
Duplicate and out of order callbacks land differently
Retry policies and ordering guarantees are not standardised between providers.
Refund and settlement semantics move underneath your accounting
Partial refunds, refund windows and settlement schedules are product decisions, not standards.
Reconciliation splits in two for the length of the transition
For a while, your ledger has to answer a question it was never asked before: which provider was this.
The webhook is not a rename. It is a different security model.
Everybody expects to rewrite the webhook handler. What they expect is a renamed header and a different payload shape. That is not what is waiting for them.
Two of the most commonly compared providers in Indonesia authenticate their callbacks in ways that are not variations of one idea. They are different ideas.
SHA512(order_id + status_code + gross_amount + ServerKey), sent as signature_key.
You recompute the hash from fields in the payload and compare. A forged payload fails, because changing the amount changes the hash.
An x-callback-token header, the same value on every webhook for your account.
You compare one string. A correct token proves the sender knew a secret. It proves nothing at all about the body of the message.
Read those two again, because the consequence is the sharpest thing in this article. A computed signature protects the contents of the message. A static token protects only the identity of the sender. If your current provider signs the amount and your next one does not, then a handler that was correct becomes a handler that cannot detect a tampered or replayed body, and it will still pass every test you wrote, because your tests send well formed messages.
This is not a criticism of either provider, and one of them says so itself: the recommended practice with a static token is to independently check that the transaction id and the amount match what you asked for when you created the charge. That check is a piece of code somebody has to remember to write during a migration that is being sold internally as a swap.
The virtual account number was never yours
This is the one that reaches your customers, and it is the one that is almost never planned for.
An Indonesian virtual account number is two parts joined together: a company code issued by the bank, and a customer portion. You can usually customise the second part. You cannot choose the first. The company code is registered with the bank to your provider, which is precisely why a gateway can offer you bank transfer across several banks without you opening an account at each one.
So the number you have been showing customers for two years does not identify your business. It identifies your provider, plus a slot in their system that happens to point at you today.
Change providers, and the prefix changes. Every fixed or saved virtual account number in existence, in a customer’s banking app favourites, in a purchase order, in a recurring transfer somebody set up and forgot, now points at an arrangement you are no longer part of. The payment does not bounce back to you with a helpful error. It simply is not yours, and the person who paid it believes they have paid you.
There is one version of this that survives, and it is worth asking about before you are locked in: a merchant can be allocated its own sub company code by the bank rather than sitting under the aggregator’s. If you have that, the number is genuinely yours and it travels. If you do not, plan a notification campaign as part of the migration and budget for the people who will never read it.
The three quieter ones
- Duplicates and ordering. Retry behaviour is a provider decision. One of the providers above retries a failed delivery up to six times with exponential backoff and says plainly that events can arrive out of sequence. If your current integration was quietly relying on events arriving once, and in order, the new one will teach you that it was. Idempotency is not a nice to have you add later, it is a property you have to port.
- Refunds. Whether a partial refund is possible, how long after settlement, and who is allowed to initiate it, are product decisions rather than standards. A refund flow that assumed one answer has a hole in it under the other, and holes in refund flows are found by customers.
- Settlement timing. Different schedule, sometimes different per channel. This is cashflow, so it is the first thing finance notices and the first thing they will attribute to the migration, correctly.
And then the one that lasts longest. For the whole overlap period your reconciliation has to answer a question your schema probably cannot: which provider settled this. If order ids are not namespaced per provider, two systems will eventually mint the same one, and the report that finds it will be a discrepancy nobody can explain three months later.
How to actually do it
The safe shape is boring and it is the same shape as any other data migration: never move anything that is in flight.
- Run both at once. The new provider goes live alongside the old one, not instead of it. If a provider cannot be run in parallel with an incumbent on your entity, that is not a migration plan, it is a cutover with no rollback, and you should know that before you sign.
- Route new orders only. Everything created from the switch onward goes to the new provider. Nothing that already has a payment instruction attached to it moves.
- Let the old one drain. Keep the old webhook endpoint alive and verified for as long as the longest payment window plus the longest refund window, which is longer than anybody estimates. Turning it off early converts a completed payment into a support ticket.
- Tag every record with its provider. Add the column before the migration, not during the incident. It is the difference between a reconciliation and an investigation.
When not to switch at all
Worth saying plainly, because in most of the nine threads the honest answer was that switching would not fix the thing they were unhappy about.
- If you were rejected or asked to upgrade at onboarding, the next provider will usually ask the same thing, because the trigger is what your product does rather than which provider you picked. A site that moves money to other people is a different risk profile everywhere. That is a question about which activity you are performing, not a question about vendors, and switching only delays the conversation to a point where you have live funds in flight.
- If it is about the fee and your volume is small, do the arithmetic before the project. A migration is engineering time, a notification campaign, a period of double reconciliation, and a window in which your least tested code path is the one handling money. A fee saving of a fraction of a percent takes a long time to repay that.
- If your client asked for it by name, ask why once, in writing. The answer changes the project. “We had a bad support experience” and “we could not pass their verification” are different problems, and only one of them is solved by a different logo on the checkout.
What is left after those three is the case that genuinely justifies the work: a provider that cannot serve a channel you need, an entity structure that will never be approved, or an outage record you can actually point at.
Six questions, asked in writing, before you commit
Sales calls answer the questions you ask. These are the ones whose answers you will otherwise discover during the migration.
How are your webhooks authenticated, and is the signature computed over the payload or a fixed token?
This decides whether your existing verification code can survive at all, and whether moving to this provider is a downgrade you need to compensate for.
Are virtual account numbers issued under your company code, or can we be allocated our own sub company code by the bank?
Your own sub code is the only version of this that survives a future migration. Ask before you have a hundred thousand saved numbers, not after.
What is the retry policy on a failed webhook, how many attempts, and can the same event arrive twice?
It sets the idempotency requirement on your side. A provider that retries six times will eventually deliver the same success event twice.
Can a settled transaction be partially refunded, and what is the cut off?
If the answer is different from your current provider, your refund flow has a hole in it on day one and nobody will notice until a customer asks.
What does the settlement schedule look like, and does it change by payment channel?
Settlement timing is cashflow. It is also the thing your finance team will compare against the bank statement and blame the migration for.
Can we run in parallel with our current provider during the switch, on the same entity?
If the answer is no, you are not migrating, you are doing a cutover with no rollback. That changes the whole plan.
What I have built, and what I have not
I have built the payment side of this end to end as a demo: signature verified webhooks, held funds, splits, full and partial refunds, and a status machine that can be replayed. It runs in test mode, it is mine, and you can open it. It is a demo rather than a production system carrying other people’s money, and I would rather say that first than have it found out later.
What I have not done is run a live migration between two Indonesian gateways for a client. The mechanics above come from the providers’ own documentation and from building against these rails, not from a war story I am dressing up as experience. If that matters for your project, and it reasonably might, it is a fair thing to weigh. The licensing side of payments I have written about separately.