Powering SA prepaid since 2003 / No monthly fees, ever
SIMcloud bulk airtime and data recharge platform

SIMcloud API

How to Integrate the SIMcloud API with Java

A technical Java integration covering secure configuration, explicit HTTP handling, durable request IDs, duplicate safety and asynchronous reconciliation.

Author: SIMcloud Published 18 July 2026
Java backend securely calling a prepaid services API with asynchronous order processing

A Java application should treat a SIMcloud recharge as an asynchronous financial operation, not as a request that is complete when an HTTP connection closes. The safe pattern is: keep the API token on your server, validate the recipient, create one order, persist the returned request_id, then poll that same request until it reaches a final state.

This guide uses Java 21's built-in java.net.http.HttpClient with Jackson for JSON. That keeps the transport explicit and works in a plain JVM service, a Spring Boot application, a worker, or a Jakarta application. The examples call the current SIMcloud wallet balance, network lookup and Airtime API endpoints; use the OpenAPI contract as the source of truth when you add more services.

Keep the bearer token server-side. Never call SIMcloud directly from an Android app, browser, JavaScript bundle or public repository. A bearer token can spend your wallet. Give mobile and browser clients a route to your own authenticated backend instead.

1. Set up Java, Jackson and runtime configuration

Use Java 21 or later. Add Jackson Databind to the service that will make the calls:

<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>2.18.3</version>
</dependency>

Inject the token through your deployment environment or a secrets manager. For a local shell, use SIMCLOUD_API_TOKEN; do not add the value to application.properties, a Docker image or a test fixture committed to Git.

String token = System.getenv("SIMCLOUD_API_TOKEN");
if (token == null || token.isBlank()) {
    throw new IllegalStateException("SIMCLOUD_API_TOKEN is not configured");
}

Use HTTPS and a finite timeout. A timeout means that your client did not receive a response; it does not prove the upstream service did not receive the request.

2. Build a small, explicit HTTP client

The client below accepts JSON, attaches the bearer token to every request and converts non-successful responses into one structured exception. It deliberately does not log request headers.

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;

public final class SimcloudClient {
    private static final URI BASE_URI = URI.create("https://simcloud.co.za");

    private final HttpClient http;
    private final ObjectMapper json;
    private final String token;

    public SimcloudClient(String token) {
        if (token == null || token.isBlank()) {
            throw new IllegalArgumentException("SIMcloud token is required");
        }

        this.token = token;
        this.json = new ObjectMapper();
        this.http = HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(10))
            .followRedirects(HttpClient.Redirect.NORMAL)
            .build();
    }

    private JsonNode send(HttpRequest request) throws IOException, InterruptedException {
        HttpResponse<String> response = http.send(
            request,
            HttpResponse.BodyHandlers.ofString()
        );

        JsonNode body = response.body().isBlank()
            ? json.createObjectNode()
            : json.readTree(response.body());

        if (response.statusCode() < 200 || response.statusCode() >= 300) {
            throw new SimcloudApiException(
                response.statusCode(),
                body.path("message").asText("SIMcloud request failed"),
                body
            );
        }

        return body;
    }

    private HttpRequest.Builder request(String path) {
        return HttpRequest.newBuilder(BASE_URI.resolve(path))
            .timeout(Duration.ofSeconds(30))
            .header("Accept", "application/json")
            .header("Authorization", "Bearer " + token);
    }

    public static final class SimcloudApiException extends RuntimeException {
        private final int statusCode;
        private final JsonNode response;

        public SimcloudApiException(int statusCode, String message, JsonNode response) {
            super(message);
            this.statusCode = statusCode;
            this.response = response;
        }

        public int statusCode() { return statusCode; }
        public JsonNode response() { return response; }
    }
}

Use HttpClient as a shared application component. Do not build a fresh client for every recharge; that throws away connection pooling and makes timeouts harder to configure consistently.

3. Verify the wallet and recipient network first

Wallet balance and network lookup are authenticated reads. They give your application enough current information to decide whether to prepare a purchase, without spending funds.

public JsonNode getBalance() throws IOException, InterruptedException {
    HttpRequest request = request("/api/balance.php")
        .GET()
        .build();

    return send(request);
}

public JsonNode lookupNetwork(String msisdn) throws IOException, InterruptedException {
    String normalized = msisdn.replaceAll("[^0-9+]", "");
    String path = "/api/network.php?msisdn="
        + java.net.URLEncoder.encode(normalized, java.nio.charset.StandardCharsets.UTF_8);

    HttpRequest request = request(path)
        .GET()
        .build();

    return send(request);
}

The network lookup response includes the current network for a valid South African mobile number. Use that result when you select a network-specific service, rather than inferring the network from the number prefix; a ported number retains its old prefix.

Read values are not an approval. A current balance and a valid recipient make a purchase possible. Your application should still show or record the final recipient, network, amount and reference before it creates a recharge.

4. Queue airtime once and capture the request ID

The Airtime API accepts msisdn, network, amount and reference. It returns HTTP 201 when the request is queued, not when the mobile network has completed delivery. Amounts are currently limited to R2–R999, and the same MSISDN plus amount is protected by a five-minute duplicate window.

import com.fasterxml.jackson.databind.node.ObjectNode;

public JsonNode createAirtimeOrder(
    String msisdn,
    String network,
    java.math.BigDecimal amount,
    String reference
) throws IOException, InterruptedException {
    ObjectNode payload = json.createObjectNode();
    payload.put("msisdn", msisdn);
    payload.put("network", network);
    payload.put("amount", amount);
    payload.put("reference", reference);

    HttpRequest request = request("/api/airtime.php")
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(json.writeValueAsString(payload)))
        .build();

    JsonNode response = send(request);
    long requestId = response.path("request_id").asLong(0);

    if (requestId <= 0) {
        throw new IllegalStateException("SIMcloud queued an order without a request_id");
    }

    return response;
}

Persist the response immediately in the same local transaction that moves your own order from ready to submitted. At minimum retain your immutable local order ID, SIMcloud request_id, recipient, network, requested amount, reference, initial HTTP status and the raw safe response body.

HTTP 409 is a stop signal. It means an order for the same MSISDN and amount was created in the last five minutes. Do not retry it as a transient failure. Find the original local order and reconcile that request instead.

5. Poll the original request to a final result

Queued and pending are non-final states. Use the returned ID with GET /api/airtime.php?request_id=…; the response becomes delivered or failed when the downstream recharge reaches a final state.

public JsonNode getAirtimeOrder(long requestId) throws IOException, InterruptedException {
    if (requestId <= 0) {
        throw new IllegalArgumentException("requestId must be positive");
    }

    HttpRequest request = request("/api/airtime.php?request_id=" + requestId)
        .GET()
        .build();

    return send(request);
}

public boolean isFinal(JsonNode order) {
    String status = order.path("status").asText();
    return "delivered".equals(status) || "failed".equals(status);
}

Run polling from a worker, scheduler or durable queue, not from the user-facing request thread. Start with bounded intervals such as 30 seconds, one minute, two minutes and five minutes, then move the order to an exception queue if it remains unresolved beyond your documented operational threshold. Store queue_status, platform_order_number, final_order_status, error and each poll timestamp as they arrive.

6. Treat a timed-out POST as unknown, not failed

This is the most important production rule. If the Java client times out after it sends the request body, you cannot know whether SIMcloud queued the airtime order. The five-minute duplicate rule reduces the risk of an accidental duplicate; it does not turn a network timeout into a safe retry.

  1. Create and persist a local order and a unique business reference before the SIMcloud call.
  2. Mark the local order submission_unknown if the connection or response times out.
  3. Do not automatically issue the same recharge again.
  4. Use the stored timing, recipient, amount, reference and application logs to reconcile the original attempt before any manual follow-up.
  5. Only retry a fresh order when an operator or an explicit reconciliation rule has established that the first request was not queued.

It is safe to retry a failed GET status request with exponential backoff. It is not automatically safe to retry a money-moving POST. Keep those two policies separate in code.

7. Load the live data catalogue before ordering data

Data bundles are not arbitrary rand amounts. Fetch the current catalogue, optionally filtered by the recipient's current network, and only submit a product your application received from SIMcloud. The live response prevents a stale bundle name or value from leaking out of a configuration file.

public JsonNode listDataProducts(String network) throws IOException, InterruptedException {
    String path = "/api/data.php?products=1";
    if (network != null && !network.isBlank()) {
        path += "&network=" + java.net.URLEncoder.encode(
            network,
            java.nio.charset.StandardCharsets.UTF_8
        );
    }

    return send(request(path).GET().build());
}

When you create the Data API request, submit the selected product's returned network and sell value with the recipient and a meaningful reference. Persist the selected product details locally, because the catalogue can change after the order has been placed.

8. Make the integration observable and supportable

A useful production record lets support answer “what happened?” without exposing a secret. Log the local order ID, SIMcloud request ID, service, recipient masked except for the last four digits, network, amount, reference, HTTP status, response status and safe failure reason. Never log the bearer token, full voucher code, electricity PIN or raw Authorization header.

  • Set explicit connection and request timeouts.
  • Use a single shared HTTP client with a controlled executor where your runtime needs one.
  • Separate validation errors (400), authentication errors (401), duplicate protection (409), and upstream/server outcomes (5xx) in your exception mapping.
  • Expose an internal reconciliation screen for orders that remain queued, pending or submission-unknown.
  • Alert on repeated 401, 409, 5xx and timeout rates; they indicate different operational problems.
  • Test with one low-value recharge to a number you control before enabling a bulk or customer-facing workflow.

Next steps

Once a single Java service can read the wallet, validate a number, queue one airtime order and reach a final status, extend the same client to SMS, data, vouchers and prepaid electricity. For electricity, validate the meter before submitting the irreversible purchase. For vouchers and data, load the live catalogue first. In every case, persist the provider identifier before you poll or retry.

Which Java HTTP library should I use?

Java 21's built-in HttpClient is sufficient and keeps the guide dependency-light. An established Spring or Jakarta HTTP client is also fine if it preserves the same explicit timeouts, bearer handling, response parsing and order lifecycle.

Can a JavaScript or Android client call SIMcloud directly?

No. Keep the bearer token on a backend you control. Mobile and browser clients should call your authenticated server, which then calls SIMcloud.

Should I retry an airtime POST after a timeout?

Not automatically. Treat it as an unknown submission and reconcile the original attempt first. Retrying status GETs is different and can use bounded backoff.

What identifies the SIMcloud airtime order?

Persist the request_id returned by the successful queue response. Use it for later GET /api/airtime.php?request_id=… polling.

Where can I see every documented endpoint?

Use the SIMcloud API documentation and its machine-readable OpenAPI contract.

Put this guide into practice

Build the first Java call, then keep every resulting order traceable

Use the current API contract, persist returned IDs and let a durable worker reconcile final delivery.