Go
The wajub-go module, its resources, and the context every call takes.
wajub-go is the server side of a Wajub integration. It holds your secret key, creates payments,
reads their real status and verifies webhook signatures. Standard library only, no dependency in
go.mod.
github.com/wajubhq/wajub-go
Stable · GAGo modules
- Version
- v1.1.1
- Runtime
- Go 1.22+, no external dependency
Covers
- Payments
- Billing
- Transfers
- Sync
- Shield
- Tax
Install
go get github.com/wajubhq/wajub-goThe import path is wajubhq, the package is wajub
The organisation is wajubhq, so github.com/wajub/wajub-go does not resolve and go get
fails with a 404 from the proxy. The package identifier inside your file is plain wajub, which
is why the import needs no alias.
Create the client
New returns an error, and it falls back to the environment, so the common case is a zero
Config.
package wajubclient
import (
"log"
"github.com/wajubhq/wajub-go"
)
// Client is built once at start-up and shared. It is safe for concurrent use.
var Client *wajub.Client
func init() {
// Reads WAJUB_API_KEY and WAJUB_WEBHOOK_SECRET when Config leaves them empty.
c, err := wajub.New(wajub.Config{})
if err != nil {
log.Fatalf("wajub: %v", err)
}
Client = c
}| Field | What it does |
|---|---|
APIKey | Your sk. or sk_test. key. Falls back to WAJUB_API_KEY |
WebhookSecret | The whsec_ secret. Falls back to WAJUB_WEBHOOK_SECRET |
IdempotencyKeyPrefix | Prefix of the generated key. Defaults to "wajub" |
HTTPClient | Your own *http.Client, for a custom transport or tracing |
MaxNetworkRetries | A *int. Defaults to 2 when nil, and 0 disables retries |
Timeout | A time.Duration. Defaults to 30 seconds, ignored when HTTPClient is set |
New returns wajub: api key is required when neither the field nor the variable holds one, so
a bad configuration fails at start-up.
Timeout is ignored when you bring your own client
Timeout configures the default *http.Client the SDK builds. Set HTTPClient and that field
is skipped entirely, so put the timeout on the client you pass.
The first call
Every method takes a context.Context first. Pass the request's context and a slow API cancels
with the request instead of holding the goroutine.
payment, err := Client.Payments.Create(ctx, map[string]any{
"amount": 25000,
"currency": "XAF",
"email": "amina@example.com",
"description": "Order 4172",
"reference": "order-4172",
"callback": "https://shop.example.com/complete",
}, nil)
if err != nil {
return fmt.Errorf("create payment: %w", err)
}
http.Redirect(w, r, payment.AuthorizationURL, http.StatusSeeOther)Parameters go in as map[string]any with the API's exact field names. The last argument is
*RequestOptions, and nil is the ordinary value.
Payment is a narrow struct, Raw holds the rest
Payment decodes six fields: ID, Status, Amount, Currency, AuthorizationURL and
AuthorizationToken. Everything else the API returned, reference and created_at included,
lives in payment.Raw as a map[string]any. Reach for Raw rather than assuming a field.
reference, _ := payment.Raw["reference"].(string)
createdAt, _ := payment.Raw["created_at"].(string)Amount is int64 and the API sends a decimal
Amounts travel in the major unit, so GHS 12.50 arrives as 12.5 and lands in Amount as
12. The struct field is safe for XAF, XOF, NGN and every zero-decimal currency, and lossy for
the rest. For a decimal currency read payment.Raw["amount"].(float64).
Every resource on the client
| Service | Methods |
|---|---|
| client.Global | Ping, Channels, Countries, Currencies |
| client.Payments | Create, Initialize, Retrieve, List, Cancel, Process, ProcessSplit, ListRefunds |
| client.Customers | Create, Retrieve, Update, Delete, List, Block, Unblock, Activate, Deactivate, ListTaxIds, CreateTaxId, DeleteTaxId |
| client.Refunds | Create, Retrieve, List |
| client.Transfers | Create, Retrieve, List |
| client.Beneficiaries | Create, Retrieve, Update, Delete, List |
| client.Links | Create, Retrieve, Update, Delete, List |
| client.Invoices | Create, Retrieve, Update, Delete, List, Send, MarkPaid, Cancel |
| client.Accounts | Create, Retrieve, Update, Delete, List, RegenerateToken |
| client.WebhookEndpoints | Create, Retrieve, Update, Delete, List, RotateSecret |
| client.Balance | Retrieve |
| client.Events | List, Retrieve, Resend |
| client.Disputes | List, Retrieve, SubmitEvidence, Accept, Close, SendMessage |
| client.Identity | Resolve, Validate |
| client.Tax | GetSettings, UpdateSettings, Rates, Calculate, Reports, ListCodes, RetrieveCode, ListRegistrations, CreateRegistration, RetrieveRegistration, UpdateRegistration, DeleteRegistration, Jurisdictions, Thresholds, ThresholdAlerts |
| client.Shield | GetSettings, UpdateSettings, Stats, ListBlocklist, AddToBlocklist, RemoveFromBlocklist |
| client.Listen | Config, Auth |
| client.Webhooks | ConstructEvent |
webhookssignature verification runs locally, no HTTP call. All other resources call the merchant REST API.links,invoices,taxandshieldare live only. A sandbox key gets403 This feature is only available in live mode.on every one of their methods.refundsandtransfersare create, retrieve and list only. The shared CRUD base also exposesupdateanddeleteon them, but the API serves no such route.
Paging through a list
page, err := Client.Payments.List(ctx, map[string]any{
"status": "success",
"per_page": 50,
})
if err != nil {
return err
}
for _, payment := range page.Data {
log.Println(payment["id"], payment["amount"])
}
if page.HasMore {
page, err = page.GetNextPage(ctx)
}
// Or collect every page at once.
all, err := page.AutoPagingIter(ctx)page.Data is []map[string]any, so rows are read by key. AutoPagingIter buys convenience with
memory, since it holds every row before returning.
Idempotency and retries
Every POST and PUT carries a generated Idempotency-Key, which is what makes the automatic
retry safe.
payment, err := Client.Payments.Create(ctx, params, &wajub.RequestOptions{
IdempotencyKey: "order-" + orderID,
})| What | Value |
|---|---|
| Retried statuses | 429, 500, 502, 503, 504, and any network failure |
| Attempts | 3 in total, one original plus two retries |
| Backoff | Exponential with jitter, honouring Retry-After on a 429 |
| Timeout | 30 seconds per request |
To turn retries off, point MaxNetworkRetries at a zero.
zero := 0
c, err := wajub.New(wajub.Config{MaxNetworkRetries: &zero})Acting for a connected account
_, err := Client.Payments.Create(ctx, params, &wajub.RequestOptions{
Sync: seller.WajubAccountID,
})Sync becomes the X-Sync header. Setup is on Sync.
Webhooks
ConstructEvent verifies the signature and returns the parsed event. It takes the body as bytes,
exactly as delivered.
func WajubWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
event, err := Client.Webhooks.ConstructEvent(
body,
r.Header.Get("X-Wajub-Signature"),
r.Header.Get("X-Wajub-Timestamp"),
0, // 0 means the default 300 second window
)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if event["event"] == "payment.succeeded" {
go fulfil(event["data"])
}
w.WriteHeader(http.StatusOK)
}Zero tolerance means the default, not none
Passing 0 as the fourth argument applies the 300 second window rather than disabling the
replay check. There is no way to turn that check off, which is deliberate.
The event name is in event, not in type, and the result is map[string]any, so read it by
key. Details on Signature verification.
Errors
Errors are pointer types wrapping a shared WajubError, so errors.As is how you inspect them.
payment, err := Client.Payments.Create(ctx, params, nil)
if err != nil {
var invalid *wajub.InvalidRequestError
if errors.As(err, &invalid) {
// invalid.Errors is map[string]string keyed by field name
return c.JSON(http.StatusUnprocessableEntity, invalid.Errors)
}
var limited *wajub.RateLimitError
if errors.As(err, &limited) {
w.Header().Set("Retry-After", strconv.Itoa(limited.RetryAfter))
w.WriteHeader(http.StatusServiceUnavailable)
return nil
}
var conn *wajub.APIConnectionError
if errors.As(err, &conn) && errors.Is(conn, context.DeadlineExceeded) {
return ErrPaymentTimedOut
}
return fmt.Errorf("wajub: %w", err)
}| Type | Returned on |
|---|---|
*AuthenticationError | 401 |
*PermissionError | 403 |
*NotFoundError | 404 |
*InvalidRequestError | 400 and 422 |
*RateLimitError | 429, with RetryAfter in seconds |
*WajubError | Every other status, and the struct embedded in all of the above |
*APIConnectionError | No response at all. It implements Unwrap |
*WebhookSignatureVerificationError | A webhook that did not verify |
Every one of them carries Message, Code, HTTPStatus, Errors and Raw.
APIConnectionError unwraps to the real cause
It keeps the transport error in Err and implements Unwrap, so
errors.Is(err, context.DeadlineExceeded) tells a timeout apart from a refused connection
without string matching.
Charging without the hosted page
payment, err := Client.Payments.Create(ctx, map[string]any{
"amount": 25000,
"currency": "XAF",
"phone": "+237670000000",
}, nil)
if err != nil {
return err
}
_, err = Client.Payments.Process(ctx, payment.ID, map[string]any{
"channel": "cm.mtn",
"phone": "+237670000000",
}, nil)A channel is country.operator, so cm.mtn is MTN Mobile Money in Cameroon. The full list is on
Payment methods. The payer still approves on their handset, so the
outcome arrives on the webhook.
Paying out
transfer, err := Client.Transfers.Create(ctx, map[string]any{
"amount": 100000,
"currency": "XAF",
"beneficiary": map[string]any{
"name": "Amina Diallo",
"channel": "cm.mtn",
"phone": "+237670000000",
},
"reference": "payout-892",
}, &wajub.RequestOptions{IdempotencyKey: "payout-892"})beneficiary also takes the ben_… id of a saved beneficiary, which is the better shape once you
pay the same person twice.
Local development
wajub listen --forward-to localhost:8080/webhooks/wajub
wajub trigger payment.succeededMore on the CLI.
Related pages
- SDK QuickstartThe same first call, in five languages side by side.
- WebhooksEvery event, and the delivery guarantees behind them.
- Naming conventionsWhy Go exports CamelCase over a snake_case wire.
- IdempotencyWhat a key protects and for how long.
- Error handlingWhich failures to retry and which to surface.
- API referenceThe endpoints behind every method above.