TL;DR
- I built
go-squad— a comprehensive, idiomatic Go SDK for the Squad by GTCO payment gateway - Zero external runtime dependencies, full test coverage, and a
squadtestmock server for unit testing integrations without hitting real APIs - This post covers the design decisions behind the SDK and what building it taught me about payment API design in the Nigerian fintech context
go get github.com/kingztech2019/go-squad
Docs: pkg.go.dev/github.com/kingztech2019/go-squad
Repo: github.com/kingztech2019/go-squad
Why I built this
I have been building fintech products in Nigeria for a while. Squad by GTCO is one of the most capable payment gateways in the market — it handles transactions, virtual accounts, transfers, VAS, sub-merchant management, disputes, and webhooks. The API surface is genuinely broad.
But every time I integrated it into a Go project, I was writing the same boilerplate. HTTP clients. JSON marshalling. Pagination loops. Webhook signature validation. Error handling for the specific error shapes Squad returns. And every project had slightly different, slightly wrong implementations of the same things.
The most common bug I kept seeing — in my own code and in code I reviewed — was the kobo confusion.
The Squad API uses kobo as the base denomination for NGN amounts. ₦5,000 is 500000 in the API. If you pass 5000 instead, you charge the customer ₦50, and nobody notices until reconciliation. It is the kind of bug that slips through tests because the payment succeeds, just for the wrong amount.
I wanted a single, well-tested library that handled all of this correctly, consistently, and idiomatically in Go. So I built one.
What the SDK covers
client := squad.New(os.Getenv("SQUAD_SECRET_KEY"))
// Payments
resp, err := client.Transactions.InitiatePayment(ctx, &squad.InitiatePaymentParams{
Email: "customer@example.com",
Amount: squad.NGN(5000), // ₦5,000 — no kobo confusion
Currency: "NGN",
CallbackURL: "https://yoursite.com/callback",
})
// Virtual accounts
account, err := client.VirtualAccounts.Create(ctx, &squad.CreateVirtualAccountParams{
CustomerIdentifier: "cust-001",
FirstName: "Adaeze",
LastName: "Okafor",
MobileNum: "2348012345678",
Email: "adaeze@example.com",
BVN: "12345678901",
DOB: "01/01/1990",
})
// Transfers
transfer, err := client.Transfers.FundsTransfer(ctx, &squad.FundsTransferParams{
TransactionRef: "pay-out-001",
Amount: squad.NGN(2000),
BankCode: "057",
AccountNumber: "0123456789",
AccountName: "John Doe",
Currency: "NGN",
})
// VAS — airtime, data, cable TV, electricity, SMS
_, err = client.VAS.BuyAirtime(ctx, &squad.BuyAirtimeParams{
PhoneNumber: "2348012345678",
Amount: squad.NGN(50),
Network: "MTN",
TransactionRef: "air-001",
})
The full surface covers transactions, virtual accounts, transfers, sub-merchant management, disputes with evidence upload, all VAS operations, and webhooks.
Design decisions worth talking about
1. Money helpers that prevent the most common bug
squad.NGN(5000) // → 500000 (₦5,000 in kobo)
squad.NGN(1) // → 100 (₦1.00 in kobo)
squad.USD(50) // → 5000 ($50.00 in cents)
squad.FromKobo(50000) // → 500.0 (display value)
squad.FromCents(5000) // → 50.0 (display value)
This is the first thing I added. Every amount field in the SDK is int64 (kobo), not float64 (naira). You cannot accidentally pass a naira amount — the type system pushes you toward the helpers. If you call squad.NGN(5000), you get 500000. There is no ambiguity.
This sounds obvious. But I have seen this bug in production codebases multiple times. The fix is architectural — make the wrong thing hard to write.
2. Auto-pagination iterator
Every listing endpoint in the Squad API is paginated. Most codebases I have seen implement the same manual loop:
// Without iterator — repetitive and error-prone
page := 1
for {
result, _ := client.Transfers.GetAllTransactions(ctx, &squad.TransferListParams{Page: page, PerPage: 50})
for _, t := range result.Transfers { process(t) }
if len(result.Transfers) < 50 { break }
page++
}
The SDK exposes an Iter type that fetches pages lazily:
// With iterator — clean, handles all edge cases automatically
iter := client.Transfers.All(ctx, &squad.TransferListParams{PerPage: 50})
for iter.Next() {
process(iter.Item())
}
if err := iter.Err(); err != nil { log.Fatal(err) }
The iterator is generic — Iter[T] — so it works across all listing endpoints with full type safety. Page fetching happens lazily on each Next() call, so you only pay for the pages you actually consume.
This pattern is borrowed from the Stripe Go SDK, which I think is the gold standard for payment SDK design.
3. Idempotency keys to prevent duplicate charges
Network failures during payment requests are a real problem in Nigeria. If you retry a payment initiation request after a timeout, you risk charging the customer twice.
The SDK supports idempotency keys at the context level:
key, err := squad.GenerateIdempotencyKey()
// Store key in your DB alongside the order BEFORE calling Squad.
ctx = squad.WithIdempotencyKey(ctx, "order-"+orderID+"-"+key)
resp, err := client.Transactions.InitiatePayment(ctx, params)
// On network failure, retry with the SAME ctx — same key, no double charge.
Or you can opt into automatic key generation for all POST requests:
client := squad.New(key, squad.WithAutoIdempotency())
The important design decision here is that idempotency is attached to the context, not the params struct. This means you can inject it from your middleware layer without touching your business logic.
4. The webhook router
Webhook handling in most codebases is a giant switch statement on event type strings, with manual signature validation scattered across the handler. The SDK ships a typed webhook router:
router := squad.NewWebhookRouter(os.Getenv("SQUAD_SECRET_KEY")).
OnTransactionSuccess(func(ctx context.Context, body *squad.WebhookTransactionBody) error {
return fulfillOrder(body.TransactionRef, body.Amount)
}).
OnVirtualAccountCredit(func(ctx context.Context, body *squad.WebhookVirtualAccountBody) error {
return creditCustomer(body.CustomerIdentifier, body.Amount)
}).
OnDisputeOpened(func(ctx context.Context, body *squad.WebhookDisputeBody) error {
return notifyTeam(body.TicketID, body.Reason)
}).
OnError(func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("webhook error: %v", err)
http.Error(w, "error", http.StatusInternalServerError)
})
http.Handle("/webhook/squad", router)
The router validates the HMAC-SHA512 signature, parses the body into the correct typed struct based on the event type, and dispatches to your handler. It implements http.Handler directly so you can register it with any standard Go HTTP framework.
Signature validation uses constant-time comparison to prevent timing attacks.
5. The squadtest mock server
This is the part I am most pleased with.
Testing payment integrations is painful. You either hit a real sandbox (slow, flaky, requires network) or you mock at the HTTP level (verbose, not type-safe). The squadtest package provides a full mock Squad API server:
func TestMyCheckoutService(t *testing.T) {
srv := squadtest.NewServer(t) // starts a mock server, shuts down when test ends
srv.OnInitiatePayment(func(p *squad.InitiatePaymentParams) (*squad.InitiatePaymentResponse, error) {
if p.Amount != squad.NGN(5000) {
t.Errorf("unexpected amount: %d", p.Amount)
}
return &squad.InitiatePaymentResponse{
CheckoutURL: "https://fake-checkout.squadco.com/abc",
TransactionRef: p.TransactionRef,
}, nil
})
// Inject srv.Client() into your service under test
myService := checkout.NewService(srv.Client())
url, err := myService.StartCheckout("customer@example.com", squad.NGN(5000))
if err != nil { t.Fatal(err) }
// Assert on what your code actually sent
if srv.RequestCount() != 1 {
t.Errorf("expected 1 request, got %d", srv.RequestCount())
}
}
The mock server:
- Starts and stops automatically with the test lifecycle via
t.Cleanup - Returns typed responses that match the real API shape
- Records all requests so you can assert on what your code sent
- Supports custom handlers for any endpoint via
srv.Handle(method, path, fn)
No real network calls. No sandbox credentials needed in CI. Fast and deterministic.
go get github.com/kingztech2019/go-squad/squadtest
What building this taught me
Nigerian payment APIs have genuinely different constraints from Western ones.
The kobo denomination issue is the obvious one. But there are subtler things. The NUBAN virtual account flow requires BVN, and building that into a type-safe API surface means thinking carefully about which fields are always required versus sometimes required. The VAS surface — airtime, data, cable TV, electricity — reflects a market where bills are paid through payment APIs rather than through bank direct debits. That changes the shape of the API significantly.
Zero external runtime dependencies is worth the effort.
The SDK has no runtime dependencies beyond the Go standard library. Every dependency you add is a dependency your users have to manage, audit, and update. For a payments library — where supply chain security matters — keeping the dependency surface minimal is not just aesthetics. It is a security posture.
The test infrastructure is part of the product.
The squadtest package is not a nice-to-have. It is one of the most important parts of the library. If developers cannot test their integration easily, they will not write tests. If they do not write tests, they will have production bugs. A payment SDK that makes testing hard is a payment SDK that ships broken integrations into production.
Using it in your project
go get github.com/kingztech2019/go-squad
Requires Go 1.21 or later.
Sandbox is auto-detected from your key prefix — if your key starts with sandbox_sk_, the SDK routes to the sandbox API automatically. No configuration needed.
import (
"context"
"os"
squad "github.com/kingztech2019/go-squad"
)
client := squad.New(os.Getenv("SQUAD_SECRET_KEY"))
Full documentation on pkg.go.dev: pkg.go.dev/github.com/kingztech2019/go-squad
Contributing
Contributions are welcome. The areas I am most interested in help with:
- More
squadtesthandler coverage for endpoints not yet mocked - Additional examples in the
examples/directory - Documentation improvements
See CONTRIBUTING.md for guidelines.
Discussion
A few questions I am genuinely curious about from the Go community:
If you have integrated payment gateways in Go before — what is the most common mistake you have seen in payment integration code? And is there a pattern in the SDK you would have designed differently?
Drop your thoughts in the comments. I read everything.