Skip to content

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 · GA

Go modules

Version
v1.1.1
Runtime
Go 1.22+, no external dependency

Covers

  • Payments
  • Billing
  • Transfers
  • Sync
  • Shield
  • Tax

Install

The module path and the package name differ
go get github.com/wajubhq/wajub-go

Create the client

New returns an error, and it falls back to the environment, so the common case is a zero Config.

wajubclient/client.go
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
}
FieldWhat it does
APIKeyYour sk. or sk_test. key. Falls back to WAJUB_API_KEY
WebhookSecretThe whsec_ secret. Falls back to WAJUB_WEBHOOK_SECRET
IdempotencyKeyPrefixPrefix of the generated key. Defaults to "wajub"
HTTPClientYour own *http.Client, for a custom transport or tracing
MaxNetworkRetriesA *int. Defaults to 2 when nil, and 0 disables retries
TimeoutA 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.

Create a payment
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.

Reading past the six struct fields
reference, _ := payment.Raw["reference"].(string)
createdAt, _ := payment.Raw["created_at"].(string)

Every resource on the client

ServiceMethods
client.GlobalPing, Channels, Countries, Currencies
client.PaymentsCreate, Initialize, Retrieve, List, Cancel, Process, ProcessSplit, ListRefunds
client.CustomersCreate, Retrieve, Update, Delete, List, Block, Unblock, Activate, Deactivate, ListTaxIds, CreateTaxId, DeleteTaxId
client.RefundsCreate, Retrieve, List
client.TransfersCreate, Retrieve, List
client.BeneficiariesCreate, Retrieve, Update, Delete, List
client.LinksCreate, Retrieve, Update, Delete, List
client.InvoicesCreate, Retrieve, Update, Delete, List, Send, MarkPaid, Cancel
client.AccountsCreate, Retrieve, Update, Delete, List, RegenerateToken
client.WebhookEndpointsCreate, Retrieve, Update, Delete, List, RotateSecret
client.BalanceRetrieve
client.EventsList, Retrieve, Resend
client.DisputesList, Retrieve, SubmitEvidence, Accept, Close, SendMessage
client.IdentityResolve, Validate
client.TaxGetSettings, UpdateSettings, Rates, Calculate, Reports, ListCodes, RetrieveCode, ListRegistrations, CreateRegistration, RetrieveRegistration, UpdateRegistration, DeleteRegistration, Jurisdictions, Thresholds, ThresholdAlerts
client.ShieldGetSettings, UpdateSettings, Stats, ListBlocklist, AddToBlocklist, RemoveFromBlocklist
client.ListenConfig, Auth
client.WebhooksConstructEvent
  • webhooks signature verification runs locally, no HTTP call. All other resources call the merchant REST API.
  • links, invoices, tax and shield are live only. A sandbox key gets 403 This feature is only available in live mode. on every one of their methods.
  • refunds and transfers are create, retrieve and list only. The shared CRUD base also exposes update and delete on them, but the API serves no such route.

Paging through a list

One page, or all of them
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.

An order number is the best key
payment, err := Client.Payments.Create(ctx, params, &wajub.RequestOptions{
	IdempotencyKey: "order-" + orderID,
})
WhatValue
Retried statuses429, 500, 502, 503, 504, and any network failure
Attempts3 in total, one original plus two retries
BackoffExponential with jitter, honouring Retry-After on a 429
Timeout30 seconds per request

To turn retries off, point MaxNetworkRetries at a zero.

No retry at all
zero := 0
c, err := wajub.New(wajub.Config{MaxNetworkRetries: &zero})

Acting for a connected account

One call, one seller
_, 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.

A net/http handler
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.

Match the specific one first
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)
}
TypeReturned on
*AuthenticationError401
*PermissionError403
*NotFoundError404
*InvalidRequestError400 and 422
*RateLimitError429, with RetryAfter in seconds
*WajubErrorEvery other status, and the struct embedded in all of the above
*APIConnectionErrorNo response at all. It implements Unwrap
*WebhookSignatureVerificationErrorA 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

Mobile Money push
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

A transfer to a phone number
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

Two terminals
wajub listen --forward-to localhost:8080/webhooks/wajub
wajub trigger payment.succeeded

More on the CLI.

What did you think of this content?