We built a Paystack SDK for Go because our system demanded it
Not going to pretend this was planned. It wasn't.
Not much of a writer but this felt worth documenting. We were building a multi-tenant platform, the kind where each business on the platform has their own Paystack account, not ours. Their customers pay them directly, our platform orchestrates it, and we're responsible for every transaction that flows through it. The existing Go SDKs weren't built for that model. We evaluated what was available, decided none of it would hold up under the operational requirements we had committed to, and took ownership of building something that would.
So I built [github.com/saphemmy/paystack-go](github.com/saphemmy/paystack-go) over a few weeks, then immediately had to use it in the exact problem that motivated it. That feedback loop was uncomfortable. You find out what you got wrong very fast.
This is me writing about it while it's still fresh.
Why multi-tenancy breaks most integrations
Most Paystack integrations look like this. You put your key in an env variable, you initialise a client at startup, you use it everywhere.
That works perfectly when you have one Paystack account. It breaks the moment you have many.
In our system, each tenant authenticates to Paystack with their own sk_live_ key. When tenant A's customer pays, that money lands in tenant A's Paystack balance, not ours. The key is not just auth it's routing. You cannot share it across tenants.
So every request that touches Paystack needs a fresh client scoped to whoever is making that request. You resolve the tenant, fetch their credential from the vault, construct a client, do the work. The client is not a singleton. It cannot be.
The constraint this puts on the SDK is simple but strict: constructing a client has to be cheap, the client has to hold no mutable state after construction, and it has to be safe to use across goroutines without coordination.
The design decision that everything else follows from
New returns ClientInterface, not *Client.
func New(secretKey string, opts ...Option) (ClientInterface, error)
When I first wrote it, I returned *Client as most SDKs do. Then I started writing tests for the billing engine that used the client and realised I couldn't mock it without HTTP. Changed it to return the interface. That was the right call.
ClientInterface looks like this:
type ClientInterface interface {
Transaction() Transactor
Customer() Customeror
Plan() Planner
Subscription() Subscriber
Transfer() Transferor
Charge() Charger
Refund() Refunder
Backend() Backend
}
Your application code accepts ClientInterface. Not *Client. Your billing functions accept Transactor. Not *TransactionService. When you write tests, you pass in a mock that implements Transactor, and your billing function never knows the difference.
This also meant that New can validate the key immediately, before anything else happens:
client, err := paystack.New("sk_live_xxx")
if err != nil {
// key didn't start with sk_test_ or sk_live_
// this is paystack.ErrInvalidKey
log.Fatal(err)
}
We had multiple tenants in staging with copy-pasted keys that had trailing spaces. Found out at the client construction, not at the first failed API call with a confusing 401. That's worth the three lines of validation.
How the middleware actually looks
Credentials live in an encrypted store. Secrets Manager, in our case, with a short-lived in-memory cache in front of it because we didn't want to hit the secrets API on every single request.
func PaystackMiddleware(store CredentialStore) gin.HandlerFunc {
return func(c *gin.Context) {
tenant := c.MustGet("tenant").(*Tenant)
key, err := store.SecretKey(c.Request.Context(), tenant.ID)
if err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
return
}
client, err := paystack.New(key)
if err != nil {
// bad key in the vault — alert on this
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.Set("paystack", client)
c.Next()
}
}
Inside a handler:
client := c.MustGet("paystack").(paystack.ClientInterface)
tx, err := client.Transaction().Initialize(ctx, &paystack.TransactionInitializeParams{
Email: req.CustomerEmail,
Amount: req.AmountKobo,
})
client.Transaction() is a method call, not a field access. It returns Transactor. Pass that interface into whatever function needs it. The function doesn't care how it was constructed or whose key it's using.
Testing: this is where the Backend interface matters
Everything HTTP lives behind Backend:
type Backend interface {
Call(ctx context.Context, method, path string, params, out interface{}) error
CallRaw(ctx context.Context, method, path string, params interface{}) (*http.Response, error)
}
In tests, you pass in your own backend with WithBackend:
client, _ := paystack.New("sk_test_xxx",
paystack.WithBackend(&myMockBackend{}),
)
Your mock controls exactly what Call returns. No HTTP, no sandbox, no shared state. Our CI has run thousands of test cases against this package and made zero real Paystack API calls. That was intentional from day one.
If you need to test against the actual Paystack sandbox — maybe you're validating webhook signature behaviour end-to-end — you use the integration build tag:
PAYSTACK_TEST_KEY=sk_test_xxx go test -tags=integration ./...
Without that tag, integration tests don't run. Keeps the CI fast and keeps the sandbox clean.
The charge flow — this one will catch you off guard
Transaction.Initialize gives you a checkout URL and that's it. Simple.
The Charge service is different. It's a state machine. You call Create, you get a ChargeResult back, and then you look at Status to know what to do next:
result, err := client.Charge().Create(ctx, &paystack.ChargeCreateParams{
Email: "customer@example.com",
Amount: 150000,
Bank: &paystack.ChargeBank{
Code: "044",
AccountNumber: "0123456789",
},
})
The status you get back tells you what Paystack needs next:
-
send_pin→ callSubmitPinwith the card PIN -
send_otp→ callSubmitOTPwith the OTP Paystack sent to the customer -
send_phone→ callSubmitPhone -
send_birthday→ callSubmitBirthday -
pending→ poll withCheckPending -
success→ you're done
switch result.Status {
case "send_otp":
result, err = client.Charge().SubmitOTP(ctx, &paystack.ChargeSubmitOTPParams{
Reference: result.Reference,
OTP: otpFromCustomer,
})
case "success":
// done
}
This matches how the Paystack Charge API actually works. I didn't abstract it into a single-call convenience method because the right UX around each step depends entirely on your product. Maybe it's a modal, maybe it's a redirect, maybe it's a mobile screen. The SDK shouldn't make that call.
For the ChargeCard path — raw card details — that only applies if you have PCI scope. Most of us should be using authorization_code from a previous transaction or the standard checkout flow.
Mobile money works too:
&paystack.ChargeCreateParams{
Email: "customer@example.com",
Amount: 50000,
MobileMoney: &paystack.ChargeMoMo{
Phone: "0241234567",
Provider: "mtn",
},
}
Amounts. Kobo. I'm going to say it once.
int64. Kobo. 1 NGN = 100 kobo. ₦1,500 is 150000.
The SDK does not convert. There is no FromNaira. I wrote it in the package-level doc comment and on every amount field. I still got it wrong twice while building the platform on top of it, both times from copy-pasting frontend code that was working in naira.
I didn't create a custom Amount type because that would have meant a conversion call on every amount in every request and every response. The SDK represents the API. The API uses kobo. Your domain layer handles currency.
Pointer helpers because Go doesn't have optional primitives
For optional fields in request structs I use pointer types. That means you'd normally have to do this:
ref := "my-reference"
params := &paystack.TransactionInitializeParams{
Email: "customer@example.com",
Amount: 50000,
Reference: &ref,
}
Which is ugly. So there are helpers:
params := &paystack.TransactionInitializeParams{
Email: "customer@example.com",
Amount: 50000,
Reference: paystack.String("my-reference"),
Currency: paystack.String("NGN"),
}
String, Int, Int64, Bool, Float64. That's it. Took me ten minutes to write and saves the annoyance every time you set an optional field.
Idempotency — set it, don't think about it
The Params base struct has IdempotencyKey *string. Set it on any write operation and the SDK forwards it as the Idempotency-Key header.
&paystack.TransactionInitializeParams{
Params: paystack.Params{
IdempotencyKey: paystack.String("tenant-123:order-456:req-789"),
},
Email: "customer@example.com",
Amount: 100000,
}
The SDK never generates one for you. That's deliberate. The right idempotency key depends on your system's semantics — your order ID, your request ID, your job ID. The SDK doesn't know what that is.
In our multi-tenant setup we namespace them: {tenantID}:{operationID}:{requestID}. Two tenants can generate the same UUID without collision.
Webhooks and the multi-tenant routing problem
Paystack sends webhooks to one URL. With 50 tenants you need one URL to dispatch to 50 different processing contexts, each with their own webhook secret.
We encode the tenant in the path:
POST /webhooks/paystack/{tenantSlug}
The handler:
func WebhookHandler(store TenantStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "tenantSlug")
secret, err := store.WebhookSecret(r.Context(), slug)
if err != nil {
http.Error(w, "", http.StatusNotFound)
return
}
event, err := paystack.ParseWebhook(r, secret)
if err != nil {
// paystack.ErrInvalidSignature if HMAC doesn't match
http.Error(w, "", http.StatusUnauthorized)
return
}
switch event.Type {
case paystack.EventChargeSuccess:
var result paystack.ChargeResult
_ = json.Unmarshal(event.Data, &result)
// handle it
case paystack.EventSubscriptionDisable:
// handle it
case paystack.EventChargeDisputeCreate:
// this one surprised me — see below
}
w.WriteHeader(http.StatusOK)
}
}
ParseWebhook combines three things: reading the body (capped at 1 MiB — MaxWebhookBodyBytes), verifying the HMAC against the X-Paystack-Signature header using constant-time comparison, and parsing the event. If you want the pieces separately, Verify and ParseEvent are there. In a standard HTTP handler I use ParseWebhook because there's no reason to split them.
event.Data is json.RawMessage. You unmarshal it yourself into whatever type you expect for that event. That's intentional. No typed union, no reflection magic. The API can add fields to the data payload at any time and you decide how to handle it.
One thing I noticed when writing the EventType constants: Paystack has dispute webhook events. charge.dispute.create, charge.dispute.remind, charge.dispute.resolve. I wasn't handling disputes in the platform at all until I saw those constants and realised that was a gap. Not a fun thing to discover but better to discover it from your own constant definitions than from a live dispute going unhandled.
Errors
One type. Branch on Code.
var pErr *paystack.Error
if errors.As(err, &pErr) {
switch pErr.Code {
case paystack.ErrCodeRateLimited:
// pErr.RetryAfter is parsed from the Retry-After header
return queue.RetryAfter(pErr.RetryAfter)
case paystack.ErrCodeInvalidRequest:
// pErr.Fields has per-field validation messages from Paystack
log.Printf("validation failed: %v", pErr.Fields)
return ErrBadRequest
case paystack.ErrCodeNotFound:
return ErrTransactionNotFound
case paystack.ErrCodeServerError:
// could be a CDN error, pErr.RawBody has what Paystack actually returned
log.Printf("paystack server error: %s", pErr.RawBody)
}
}
RetryAfter is parsed from Paystack's Retry-After header. The SDK surfaces it. What you do with it — re-queue, sleep, surface to the user — is your decision. We re-queue in billing jobs, we return an error in API handlers.
RawBody is the raw response bytes. I've needed it twice: once when Paystack's CDN returned an HTML error page and I needed to see what was actually there, once when a response shape didn't match what I expected. It's there, it's exported, most of the time you ignore it.
The SDK doesn't retry anything. Ever. I want to be clear about that because a few people asked. The retry policy depends on where the call is happening — a web handler, a background job, a bulk billing run. Those have completely different right answers. The SDK gives you the signal. You decide what to do with it.
What the package doesn't do
No retry logic. Return the error with enough context for the caller to decide.
No currency conversion. Kobo in, kobo out.
No built-in logging, but there are Logger and LeveledLogger interfaces you can wire in with WithLogger or WithLeveledLogger. We use zerolog internally — I implemented LeveledLogger in about 8 lines and passed it in. SDK logs go into our structured log stream.
No framework lifecycle management. That's for the integration packages — paystackgin, paystackfiber, paystackecho — which are separate modules.
It's at github.com/saphemmy/paystack-go, MIT. Contributions welcome.
If you build something on it and run into something unexpected, open an issue. Will actually look at them.
Top comments (1)
Scratching your own itch is how the best open source tools get built. A well-tested Paystack Go SDK will save many developers hours of raw API integration work. Great contribution!