Java
The com.wajub wajub-java artifact, its services, and its unchecked exceptions.
wajub-java is the server side of a Wajub integration. It holds your secret key, creates
payments, reads their real status and verifies webhook signatures. OkHttp for transport, Jackson
for JSON, nothing else.
com.wajub:wajub-java
Stable · GAMaven Central
- Version
- 1.1.1
- Runtime
- Java 17+, OkHttp 4.12, Jackson 2.18
Covers
- Payments
- Billing
- Transfers
- Sync
- Shield
- Tax
Install
<dependency>
<groupId>com.wajub</groupId>
<artifactId>wajub-java</artifactId>
<version>1.1.1</version>
</dependency>The artifact is compiled with --release 17, so Java 17 is the floor and anything newer runs it.
It pulls OkHttp 4.12.0 and jackson-databind 2.18.2 transitively. If your application already
pins either one, the usual dependency management applies.
The group is com.wajub
co.wajub resolves to nothing. The published coordinates are com.wajub:wajub-java, and the
mobile artifacts sit under the same group.
Create the client
Wajub.create is the entry point. It falls back to the environment, so the short form is enough
in most applications.
import com.wajub.Wajub;
// Reads WAJUB_API_KEY and WAJUB_WEBHOOK_SECRET when the field is blank.
Wajub wajub = Wajub.create(Wajub.Config.builder().build());
// Or spell everything out.
Wajub wajub = Wajub.create(
Wajub.Config.builder()
.apiKey(System.getenv("WAJUB_API_KEY"))
.webhookSecret(System.getenv("WAJUB_WEBHOOK_SECRET"))
.build());
// Or pass just the key.
Wajub wajub = Wajub.create(System.getenv("WAJUB_API_KEY"));| Builder method | 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 OkHttpClient, for an interceptor, a proxy or tracing |
A blank key raises IllegalArgumentException: wajub: api key is required at construction, so a
bad configuration fails at start-up rather than on the first payment.
@Configuration
public class WajubConfiguration {
@Bean
Wajub wajub(@Value("${wajub.api-key}") String apiKey,
@Value("${wajub.webhook-secret}") String webhookSecret) {
return Wajub.create(
Wajub.Config.builder()
.apiKey(apiKey)
.webhookSecret(webhookSecret)
.build());
}
}The client is thread safe and holds an OkHttp connection pool, so one singleton for the application is the right shape.
The first call
import com.wajub.Payment;
Payment payment = wajub.payments().create(
Map.of(
"amount", 25000,
"currency", "XAF",
"email", "amina@example.com",
"description", "Order 4172",
"reference", "order-4172",
"callback", "https://shop.example.com/complete"),
null);
return "redirect:" + payment.getAuthorizationUrl();Resources are methods, not fields, so it is wajub.payments() and not wajub.payments.
Parameters go in as a Map<String, Object> with the API's exact field names.
RequestOptions has no overload, pass null
Every mutating method takes options as its last argument and there is no two argument version.
null is the ordinary value, and Map.of refuses a null value, so use HashMap when a field
may be absent.
Payment.getAmount is a long and the API sends a decimal
Payment decodes six fields, and getAmount() returns a long through Number.longValue(),
which truncates. GHS 12.50 comes back as 12. The accessor is safe for XAF, XOF and every
zero-decimal currency, and lossy for the rest. For a decimal currency read
payment.getRaw().get("amount"), which is the untouched value.
String reference = (String) payment.getRaw().get("reference");
Number amount = (Number) payment.getRaw().get("amount");Amounts are in the major unit
25000 with XAF is twenty-five thousand francs, not two hundred and fifty.
Every service 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 |
webhooks/ConstructEventruns locally, no HTTP call. All other services 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
PagedResult page = wajub.payments().list(Map.of("status", "success", "per_page", 50));
for (Map<String, Object> payment : page.getData()) {
log.info("{} {}", payment.get("id"), payment.get("amount"));
}
if (page.hasMore()) {
page = page.getNextPage();
}
// Or collect every page at once.
List<Map<String, Object>> all = page.autoPaging();Rows are Map<String, Object>, not typed objects. autoPaging() 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 payment = wajub.payments().create(
params,
new RequestOptions().setIdempotencyKey("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 to connect, read and write |
The retry count is fixed. To change the timeouts, hand the builder your own OkHttpClient.
OkHttpClient http = new OkHttpClient.Builder()
.connectTimeout(Duration.ofSeconds(10))
.readTimeout(Duration.ofSeconds(60))
.addInterceptor(new TracingInterceptor())
.build();
Wajub wajub = Wajub.create(Wajub.Config.builder().httpClient(http).build());Acting for a connected account
wajub.payments().create(
params,
new RequestOptions().setSync(seller.getWajubAccountId()));setSync 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.
@PostMapping(value = "/webhooks/wajub", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> wajubWebhook(
@RequestBody byte[] body,
@RequestHeader("X-Wajub-Signature") String signature,
@RequestHeader("X-Wajub-Timestamp") String timestamp) {
Map<String, Object> event;
try {
event = wajub.webhooks().constructEvent(body, signature, timestamp, null);
} catch (WebhookSignatureVerificationException e) {
return ResponseEntity.badRequest().build();
}
if ("payment.succeeded".equals(event.get("event"))) {
fulfilmentQueue.submit(event);
}
return ResponseEntity.ok().build();
}Bind byte[], never a DTO
The signature covers {timestamp}.{raw body}. Letting Jackson deserialise into a class and
re-serialising changes key order and spacing, so the hash stops matching. @RequestBody byte[]
hands you the bytes as delivered, which is what constructEvent wants.
A null tolerance means the default 300 second window, not an absent check. There is no way to
disable the replay protection, which is deliberate.
The event name is in event, not in type, and the result is a Map<String, Object>. Details on
Signature verification.
Errors
import com.wajub.exception.*;
try {
wajub.payments().create(params, null);
} catch (InvalidRequestException e) {
// e.getErrors() is {"amount": "The amount must be at least 25."}
return ResponseEntity.unprocessableEntity().body(e.getErrors());
} catch (RateLimitException e) {
return ResponseEntity.status(503)
.header("Retry-After", String.valueOf(e.getRetryAfter()))
.build();
} catch (WajubException e) {
log.error("wajub failed code={} status={}", e.getCode(), e.getHttpStatus());
throw e;
}Class in com.wajub.exception | Thrown on |
|---|---|
AuthenticationException | 401 |
PermissionException | 403 |
NotFoundException | 404 |
InvalidRequestException | 400 and 422 |
RateLimitException | 429, with getRetryAfter() in seconds |
WajubException | Every other status, and the parent of all of the above |
ApiConnectionException | No response at all, network or timeout |
WebhookSignatureVerificationException | A webhook that did not verify |
All of them expose getMessage(), getCode(), getHttpStatus(), getErrors() and getRaw().
These are unchecked, despite the throws clause
WajubException extends RuntimeException, so the throws WajubException on every method is
documentation rather than a compiler obligation. Nothing forces you to handle a failed payment,
and an uncaught one becomes a 500. Catch it deliberately.
getErrors() returns Map<String, String>, one message per field, where the API sends an array
per field. Only the first message of each field survives the mapping.
Testing without touching the API
The base URL is a constant with no environment override, so a mock server cannot be pointed at.
The seam is the OkHttpClient: an interceptor that answers before the call leaves makes the whole
SDK offline.
Interceptor stub = chain -> new Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(201)
.message("Created")
.body(ResponseBody.create("""
{"code":201,
"authorization_url":"https://pay.wajub.com/tok_test",
"authorization_token":"tok_test",
"transaction":{"id":"trx_test","status":"pending","amount":25000}}
""", MediaType.get("application/json")))
.build();
Wajub wajub = Wajub.create(
Wajub.Config.builder()
.apiKey("sk_test.fake")
.httpClient(new OkHttpClient.Builder().addInterceptor(stub).build())
.build());
Payment payment = wajub.payments().create(Map.of("amount", 25000, "currency", "XAF"), null);
assertEquals("tok_test", payment.getAuthorizationToken());Only the Node.js SDK reads WAJUB_API_URL
@wajub/node lets that variable redirect the base URL, which is handy against a local stack.
Java, Python, PHP, Go, Ruby and C# all hold https://api.wajub.com as a constant. Injecting a
transport, as above, is the way to intercept them.
Charging without the hosted page
Payment payment = wajub.payments().create(
Map.of("amount", 25000, "currency", "XAF", "phone", "+237670000000"),
null);
wajub.payments().process(
payment.getId(),
Map.of("channel", "cm.mtn", "phone", "+237670000000"),
null);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
Map<String, Object> transfer = wajub.transfers().create(
Map.of(
"amount", 100000,
"currency", "XAF",
"beneficiary", Map.of(
"name", "Amina Diallo",
"channel", "cm.mtn",
"phone", "+237670000000"),
"reference", "payout-892"),
new RequestOptions().setIdempotencyKey("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.
- Android (Kotlin)The mobile artifacts under the same com.wajub group.
- WebhooksEvery event, and the delivery guarantees behind them.
- Naming conventionsWhy Java exposes camelCase over a snake_case wire.
- Error handlingWhich failures to retry and which to surface.
- API referenceThe endpoints behind every method above.