I noticed every payment provider, promises to make integration and usage simple. They all have the "Just simply call our endpoint" notion. But when I actually start integrating, I realize how different they all were and behind the "Just simply call our endpoint" notion they were all different in their own way (which is not bad but would be a little tedious for me if I were to integrate multiple payment processors).
I found out Stripe wants cents, PayPal wants decimal strings, Square won't even allow me do anything without an idempotency key, and Paystack needs my customers to leave my site entirely before any money moves.
As a developer I have always had this notion, "Do it incredibly hard the first time, to make it incredibly simple to do any other time". I didn't want to rewrite payment code every time I switched providers. So I built PayBox one Go interface that wraps multiple providers using a single interface.
Let me work you through how my mind worked when I was doing this
The Starting Point
I wanted to write this once and have it work with any provider:
payment, err := proc.CreatePayment(ctx, processor.CreatePaymentRequest{
Amount: 1500,
Currency: "USD",
CustomerEmail: "customer@example.com",
})
Doesn't matter if proc is Stripe, Square, PayPal, Paystack or any other provider. I simply wanted same call, same types. Whatever's different gets handled inside each provider.
The interface I landed on:
type PaymentProcessor interface {
CreatePayment(ctx context.Context, req CreatePaymentRequest) (*Payment, error)
GetPayment(ctx context.Context, paymentID string) (*Payment, error)
RefundPayment(ctx context.Context, req RefundRequest) (*Refund, error)
VerifyWebhook(payload []byte, signature string) (*WebhookEvent, error)
ProviderName() string
}
It inlcudes five methods. Create, fetch, refund, verify webhooks, and identify yourself. This covers most of what I or any developer would need for a real integration.
The hard question was whether four very different providers (which us the number of providers I used for this project) would actually fit into this shape.
Stripe Was the Easy One
I started with Stripe, it was quite easy for me because they had an official Go SDK, solid documentation, and worked perfectly when i tried it out as a single payment provider. You call CreatePayment, pass a test card token, money moves, status: succeeded comes back. Done.
proc := stripe.New("sk_test_...", stripe.Config{WebhookSecret: "whsec_..."})
Had it working in no time, the developer experience is quite amazing with Stripe.
Square Looked Familiar, Then It Wasn't
Square has an official Go SDK too, so I dived in thinking it would be the same experience as Stripe. Not that it was really hard or something, and also the developer experience was good too but it was different in the way it approached payments.
First thing that got me: idempotency keys are mandatory on every write request. While Stripe lets you optionally add one, Square rejects your request entirely if you don't include one. I wasted maybe an hour on this before I realized what was happening. Ended up just auto-generating a random idempotency key for the purpose of this project.
func generateIdempotencyKey() string {
return fmt.Sprintf("paybox_%d", time.Now().UnixNano())
}
In production you might want to craft a better implementation to increase uniqueness though.
Then there is the approach to how currency are added in requests, Stripe takes "usd" which is just a string. Square wants you to use their typed enum: CurrencyUsd. So I had to write a mapping layer:
func mapCurrency(code string) (gosquare.Currency, error) {
switch code {
case "USD", "usd":
return gosquare.CurrencyUsd, nil
case "NGN", "ngn":
return gosquare.CurrencyNgn, nil
// ... etc
}
}
None of this is hard on its own, but it is exactly the kind of differences that makes switching providers painful if you don't have an abstraction layer. Your app says "USD", each provider translates it however it needs to.
The payment flow itself works like Stripe though, server-side completion. Pass a test nonce (cnon:card-nonce-ok), get succeeded back. No redirects.
PayPal Was Actually different
PayPal doesn't have an official Go SDK. No typed structs, no generated client. Just me, net/http, and their REST API docs.
I actually chose PayPal partly for this reason. I wanted PayBox to demonstrate what happens when there's no SDK to lean on. But PayPal had bigger surprises in store than just "write your own HTTP calls."
The Auth Situation
Stripe: put your API key in a header. Square: put your access token in a header. PayPal: oh, you want to make an API call? First, POST your client ID and secret to an OAuth2 endpoint, get back a temporary token, and use that. And by the way, it expires.
So now I have to build token caching:
func (c *Client) getAccessToken(ctx context.Context) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.accessToken != "" && time.Now().Before(c.tokenExpiry.Add(-60*time.Second)) {
return c.accessToken, nil
}
// ... fetch new token from /v1/oauth2/token
}
Mutex because this needs to be thread-safe. 60-second buffer before expiry so you don't get caught sending a request with a token that dies mid-flight. If Paypal had an official Go SDK I wouldn't have had to do this, but then again writing it myself was honestly a good exercise in understanding how OAuth2 client credentials actually work under the hood.
The Redirect Flow
With Stripe and Square, calling CreatePayment finishes the job. Payment's done, money moved.
PayPal doesn't work that way. CreatePayment creates an order, and then your customer has to physically go to PayPal's website, log in, and click approve. Only after that can you call CaptureOrder to actually get the money.
This was the moment I found out if my interface design was actually good or just looked good on paper. My question was, can the same CreatePayment method handle "payment is complete" AND "customer needs to go somewhere else first"?
Turns out it can, with two fields:
type Payment struct {
Status PaymentStatus // "succeeded" or "requires_action"
RedirectURL string // empty for Stripe/Square, a URL for PayPal/Paystack
// ...
}
Stripe gives you succeeded with no redirect URL. PayPal gives you requires_action and a URL to sandbox.paypal.com/checkoutnow?token=.... Your app just looks at both and decides what to do next. I won't lie, I was pretty relieved when this actually worked cleanly.
Oh, and PayPal Doesn't Use Cents
This one caught me off guard. Stripe and Square both take 1500 to mean $15.00 (amount in cents). PayPal wants the string "15.00". Different format entirely.
So every PayPal request had to go through a conversion:
func formatAmount(amount int64, currency string) string {
switch currency {
case "JPY", "KRW", "VND":
return fmt.Sprintf("%d", amount)
default:
return fmt.Sprintf("%.2f", float64(amount)/100.0)
}
}
The interface always takes cents. Each provider figures out its own format internally. Zero-decimal currencies like JPY get special handling because 1500 yen is actually 1500 yen, not 15 yen.
Paystack
As a Nigerian currently living in Lagos, Paystack is one of the most popular payment processor here. Acquired by Stripe a few years back, handles cards, bank transfers, USSD, mobile money basically everything Nigerians actually use to pay for things.
No official Go SDK here either, so it's raw HTTP again like PayPal. But the auth is refreshingly simple after the PayPal OAuth2 experience:
req.Header.Set("Authorization", "Bearer "+c.secretKey)
One header. No token fetching, no expiry, no mutex. Your secret key goes straight in the request. After building PayPal's whole OAuth2 flow, this was quite easy to implement for Paystack.
The payment flow is redirect-based though, same as PayPal. Initialize a transaction, get back a checkout URL, send your customer there. So my interface handles it identically, using the requires_action and RedirectURL fields I created earlier in Payment struct.
Where Paystack really wins over PayPal for developer experience is testing. Paystack gives you actual test card numbers. Open the checkout URL in your browser, punch in 4084 0840 8408 4081, any future expiry, CVV 408, OTP 123456, payment goes through. PayPal makes you log in with some sandbox buyer account you set up in their developer dashboard. Way more complicating.
Running It All Together
This is what it looks like when you fire the same payment at all four providers:
╔══════════════════════════════════════════════════╗
║ PayBox — Multi-Provider Payment Comparison ║
╚══════════════════════════════════════════════════╝
Found 4 provider(s): stripe, square, paystack, paypal
── STRIPE ───────────────────────────────────────
Status: succeeded
Amount: USD 15.00
→ ✅ Payment complete. Deliver the goods.
── SQUARE ───────────────────────────────────────
Status: succeeded
Amount: USD 15.00
→ ✅ Payment complete. Deliver the goods.
── PAYSTACK ─────────────────────────────────────
Status: requires_action
Amount: NGN 15.00
Redirect: https://checkout.paystack.com/1gipbcvudltt2p9
→ 🔗 Redirect customer to complete payment.
── PAYPAL ───────────────────────────────────────
Status: requires_action
Amount: USD 15.00
Redirect: https://www.sandbox.paypal.com/checkoutnow?token=...
→ 🔗 Redirect customer to complete payment.
Same function call, four providers, two completely different payment patterns. Stripe and Square finish the job on the server. PayPal and Paystack send the customer somewhere else. Your code doesn't care, it just reads payment.Status and acts accordingly.
The Stuff Your App Never Has to Think About
All of this is different between providers, and none of it leaks into your application code:
| Stripe | Square | PayPal | Paystack | |
|---|---|---|---|---|
| SDK | Official | Official | None (raw HTTP) | None (raw HTTP) |
| Auth | API key | Access token | OAuth2 + token caching | Bearer token |
| Flow | Server-side | Server-side | Browser redirect | Browser redirect |
| Amounts | Cents (int) | Cents (int) | Decimal string | Kobo (int) |
| Currency | Lowercase string | Typed enum | Uppercase string | Uppercase string |
| Idempotency | Optional | Mandatory | Not used | Not used |
| Test card | pm_card_visa |
cnon:card-nonce-ok |
Sandbox login required | 4084 0840 8408 4081 |
Four providers, four different ideology and approach to how payments should work. The interface smooths it all out.
Rooms for Improvement and Contribution Ideas
Error chains. Each provider maps errors to shared types like ErrPaymentNotFound and ErrAuthentication, which is great for your app logic. But sometimes the original provider error gets swallowed, and when you are debugging at 2am you really want to see the raw API response. You could use errors.Join or a wrapper that keeps the full chain.
Provider registry. Right now you import each provider package directly. I will rather have a paybox.Register("stripe", factory) pattern so you could pick providers from a config file without touching import statements.
Better webhook handling. The webhook server only does Stripe right now. We could have a unified handler that normalizes events from any provider into the same shape, payment.succeeded, payment.failed, refund.created — regardless of where it came from.
Go Try It
PayBox is open source: github.com/profsaz/paybox
Every provider works with free sandbox keys. No real money involved, no KYC needed for test mode. Clone it, add your keys, run the tests.
And if you want to add a fifth provider, Flutterwave, Adyen, whoever, the interface is five methods. Implement them, write some tests, send a PR.
Top comments (0)