Skip to content

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

Maven 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.

Create the client

Wajub.create is the entry point. It falls back to the environment, so the short form is enough in most applications.

Two ways to build it
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 methodWhat 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 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.

As a Spring bean
@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

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

Everything past the six decoded fields
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

ServiceMethods
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 / ConstructEvent runs locally, no HTTP call. All other services 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
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.

RequestOptions chains
Payment payment = wajub.payments().create(
    params,
    new RequestOptions().setIdempotencyKey("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 to connect, read and write

The retry count is fixed. To change the timeouts, hand the builder your own OkHttpClient.

Your own OkHttp client
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

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

A Spring Boot controller
@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();
}

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

Catch the specific one first
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.exceptionThrown on
AuthenticationException401
PermissionException403
NotFoundException404
InvalidRequestException400 and 422
RateLimitException429, with getRetryAfter() in seconds
WajubExceptionEvery other status, and the parent of all of the above
ApiConnectionExceptionNo response at all, network or timeout
WebhookSignatureVerificationExceptionA webhook that did not verify

All of them expose getMessage(), getCode(), getHttpStatus(), getErrors() and getRaw().

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.

A JUnit test with no network
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

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

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

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?