Taking payment through a gateway that belongs to another site
- Published on
- • 4 mins read•--- views
Sometimes the payment gateway is not yours. It belongs to a partner site, a different legal entity, or an entity that holds a licence you do not. The buyer has to be sent there to pay, and everything after that has to come back to you somehow.
This integration is written badly more often than it is written well, and it fails in two specific ways that are worth naming.
Failure one: the callback nobody authenticates
The remote system needs to tell you the payment succeeded, so you expose an endpoint. If that endpoint accepts a POST with an order id and a status and acts on it, then anyone who learns the URL can mark any order paid, and your shop will believe them, ship the goods, and never notice.
This is not a hypothetical. Callback URLs end up in logs, in browser history, in support tickets, in JavaScript bundles.
Two layers fix it, and you want both. A token proves the caller is allowed to call at all. A signature proves the payload was not altered on the way:
public function sign(string $body): string
{
return hash_hmac('sha256', $body, $this->key);
}
public function verify(string $body, string $signature): bool
{
return hash_equals($this->sign($body), $signature);
}
hash_equals rather than === because string comparison exits early on the first differing byte, and the time it takes leaks how much of the signature was correct. That is a real attack, and the fix costs one function name.
The signature also has to cover the raw body, not the parsed array. Parse-then-sign lets anything the parser normalises through unsigned.
Failure two: two order records that drift
The remote site creates its own order for the payment. Now the same purchase exists twice, and these two records will disagree eventually, because every pair of records in two systems does.
The instinct is a mapping table: local id, remote id, updated when either changes. It works until a write fails halfway, and then the mapping is wrong in a way nothing detects.
Store the relationship on the records themselves instead. When the remote order is created, tag it with the origin id and a source marker, so either side can find the other with a query:
wc_get_orders([
'meta_query' => [
['key' => '_order_id', 'value' => $wcOrderId],
['key' => '_source', 'value' => $orderProvider],
],
]);
There is no third artefact to keep in sync, so there is nothing to fall out of sync. The relationship is a property of the data rather than a record about the data.
The source marker matters as much as the id: two partner sites will eventually both send you order 1041.
Log the exchange, not just the result
Every request and response in this flow should land in a table: order id, remote order id, source, status, callback URL, raw payload.
The reason is disputes. A customer says they paid and you say you never heard about it. Without a log this becomes two teams reading two dashboards and disagreeing politely for a week. With one, it is a query, and you find out within a minute whether the callback arrived and what it said.
Store the raw payload specifically. A parsed summary tells you what you understood; the raw body tells you what you were sent, and the gap between those two is exactly where this class of bug lives.
Propagate status both ways
A refund or cancellation on either side has to reach the other, or one system is quietly wrong. Hang a handler off local status changes and push them out.
This is the part most implementations skip, because the happy path works without it and the failure only appears weeks later during accounting.
The general rule
When money crosses a system boundary, assume the boundary is hostile and the two sides will disagree. Authenticate the caller, sign the payload, tie the records together through their own fields, and write down every exchange. None of that is expensive up front, and all of it is extremely expensive to retrofit while a customer is waiting for an answer about their money.
Related
Projects

CactusPlay - Game shop run by a vetted contractor network
Before: CIS buyers compared PlayStation and Xbox prices by hand across scattered sellers, and every order had to be filled by the founders themselves. After: a shop with a live catalogue and a CRM any vetted contractor with Turkish payment details could join to take orders and earn on them. Result: 20,000+ positions grabbed hourly, price changes reaching the shop and Yandex.Market untouched, and fulfilment no longer capped by one pair of hands.
Posts
Make delivery a consequence of payment, not a task someone performs
Digital goods delivered by hand are limited by working hours and protected only by attention. Moving delivery onto order events changes what the system guarantees, and the API is what stops a second channel becoming a second implementation.
Pricing a catalogue against a store you do not control
Reselling means your prices depend on someone else changing theirs. Fetching on page load takes your shop down with theirs; a nightly export goes stale. The middle path is a cache with a pricing strategy behind it.
A support widget that already knows who it is talking to
Pasting a chat widget into a theme template works until the theme updates, the settings need changing, or the widget adds a database query to every page. Three small decisions fix all of it.
Open for contract collaboration
I am available for contract-based collaboration. If you have an interesting project idea, schedule a call via Calendly.
Schedule a 30-min call