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.
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