DEV Community

Prof Saz
Prof Saz

Posted on

I Built One Go Interface for Stripe, Square, PayPal, and Paystack

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",
})
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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_..."})
Enter fullscreen mode Exit fullscreen mode

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())
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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)