Every new product eventually needs to take a card payment, and in Pakistan getting a merchant account for each new store is slow. At VividSol we already had a working card gateway on the billing site our hosting customers use. The bank ties each store to one return address and one notification address, and both of ours already pointed at that billing site. So a new product could not simply reuse the credentials directly.
The answer was a small bridge: two PHP files on the billing site that send a product's customer to the bank's card page on our existing account, then report back what the bank says. Our own Laravel products use it, so one company sells its products through its own checkout. Invitation Arts, our digital invitation product, was the first to take cards through it, and LaunchLancer is next, for its subscription invoices. Each product says so at checkout and tells the customer what will appear on their card statement. That keeps the sale honest and avoids "unknown charge" disputes.
The interesting part is not the plumbing. It is the trust model.
The flow
- The product creates a signed payment link and sends the customer to the bridge.
- The bridge checks the signature and sends the customer to the bank's card page.
- The bank returns the customer to the billing site, which hands them back to the product.
- The product asks the bridge, server to server, what happened. The bridge asks the bank and returns a signed answer.
- The product marks the order paid only if that answer says "Paid" for the exact amount.
Rule 1: the browser proves nothing
The return trip through the customer's browser carries one thing: which payment to ask about. It is not signed and it is never trusted. Anyone can type a return URL into a browser. A query parameter saying status=paid is an invitation to fraud.
An order becomes paid in one way only: the product asks, the bridge answers with a signature, and the answer checks out.
Rule 2: sign everything that crosses a server boundary
Each product has its own secret, 64 hexadecimal characters, shared only with the bridge. Every message between them, including payment links, status questions, status answers, and notifications, carries an HMAC-SHA256 signature made with that secret. Signatures are compared in constant time, and a message whose timestamp is more than five minutes off the receiver's clock is refused, so a captured message cannot be replayed later.
On the Laravel side, the check on a status answer looks roughly like this:
$isPaid = hash_equals($this->bridge->sign('result', ...$signedFields), $answer['sig'])
&& $answer['ref'] === $reference
&& abs(now()->timestamp - (int) $answer['ts']) <= 300
&& strcasecmp($answer['status'], 'Paid') === 0
&& (int) round((float) $answer['amount']) === $order->total;
Every condition must hold. Anything else is "not paid," including a perfectly signed answer for a different amount.
Rule 3: links expire and carry their own terms
A payment link holds the reference, the amount, the currency, and an expiry time inside the signed payload. Our client gives links ten minutes, and the bridge refuses anything longer than fifteen. A link cannot be edited to a smaller amount, and an old link found in a browser history cannot be reused next week.
Rule 4: every attempt gets a new reference
The bank refuses a reference it has already seen, whether that payment succeeded or not. So a reference is the product's prefix, the order, and an attempt number: a customer who abandons the card page and tries again gets attempt 02, not a second try at 01. It sounds minor. Forgetting it produces payments that fail for reasons nobody can see.
Rule 5: a notification is a doorbell, not a receipt
Sometimes the bank calls the billing site on its own, for example when a customer closes the tab before returning. The bridge passes this on to the product as a signed notification. The product verifies it and then asks the status question itself, exactly as if the customer had returned. It never settles an order from what the notification says.
Settlement is also idempotent: if the return and the notification both arrive, the second one finds the order already paid and does nothing. I go deeper into these habits in API Integration Patterns I Use in Every Laravel Project.
Rule 6: log everything, and never let logging block a payment
Handshakes with the bank happen on the billing site, out of the product's sight. So the bridge sends signed reports to the product: the bank handshake, any refused link, and the bank's return codes. Each report uses a short timeout, and the product logs it but never acts on it.
The product shows all of this in an admin payment log: when the payment started, who started it, every handshake, every return, every notification. When a customer says "I paid," that page answers the question in a minute.
Rule 7: respect the oldest server in the chain
The billing site runs an older PHP version than our Laravel apps, as billing platforms often do. The bridge is written to run on PHP 7.0: no typed properties, no arrow functions, no match. It is not elegant. It is compatible, which matters more for code on the revenue path.
Rule 8: rehearse, because there is no sandbox
The bank offers no test environment. Every change to the protocol would otherwise need a real card. So the bridge comes with a rehearsal kit: stand-ins for the billing site, the bank, and a product, with about thirty end-to-end checks that run against any bridge build before it is uploaded.
After an upload, a probe script proves the deployed bridge, the secret, and the bank connection without charging anything. The final proof is the smallest real payment the bank accepts, refunded from the merchant portal afterwards.
What is not done yet
Refunds are still manual in the merchant portal. There is no email when a payment fails. The payment log is never pruned. I would rather list these than pretend the system is finished.
My take
Payments reward paranoia. Nearly every rule above exists because the obvious shortcut is a known way to lose money: trusting a redirect, trusting a notification, reusing a reference, or letting a log write block a sale. The bridge is small, but it treats every message as a claim that needs proof, and that is the right attitude for any code between a customer's card and your database. If you are adding payments to your own Laravel product, this is the kind of work I take on.