Java SDK
Create Qasimha checkouts, confirm orders and verify webhooks from a Java backend with com.qasimha:qasimha-sdk.
com.qasimha:qasimha-sdk is the Java server SDK for the Qasimha merchant API. Calls are blocking, return typed models, and raise typed (unchecked) exceptions.
Requirements
| Item | Requirement |
|---|---|
| Runtime | Java 11 or later. Works on Spring Boot 2.x and 3.x, javax and jakarta servlet containers |
| HTTP client | The JDK's java.net.http.HttpClient |
| Dependencies | Jackson (jackson-databind, jackson-datatype-jsr310) only |
| Keys | A secret key (sk_test_... for UAT). Optional: a publishable key (pk_test_...) for plan quotes, and a webhook secret (whsec_...) |
| API release | Return URLs, per-merchant webhook endpoints, getByReference and getPlans need the merchant API release of 2026-09-25 or later |
Install
<dependency>
<groupId>com.qasimha</groupId>
<artifactId>qasimha-sdk</artifactId>
<version>0.1.0</version>
</dependency>implementation("com.qasimha:qasimha-sdk:0.1.0")Configure
Create keys in the Merchant Portal under Settings → API Keys. Keep them in environment variables:
QASIMHA_API_KEY=sk_test_...
QASIMHA_PUBLISHABLE_KEY=pk_test_...
QASIMHA_WEBHOOK_SECRET=whsec_...Build one client per API key and share it; it is thread-safe.
import com.qasimha.sdk.QasimhaClient;
QasimhaClient client = QasimhaClient.builder()
.apiKey(System.getenv("QASIMHA_API_KEY"))
.webhookSecret(System.getenv("QASIMHA_WEBHOOK_SECRET"))
.publishableKey(System.getenv("QASIMHA_PUBLISHABLE_KEY"))
.language("en") // "ar" or "en"
.userAgentSuffix("my-store/1.4.0")
.build();In Spring Boot, expose the client as a @Bean built the same way from @Value properties.
The SDK picks the environment from the key prefix: sk_test_ uses UAT (sandbox), sk_live_ uses production. See Environments.
| Builder method | Default | Notes |
|---|---|---|
.apiKey(String) | — | Required. sk_test_... or sk_live_... |
.environment(Environment) | from key prefix | Environment.SANDBOX or Environment.PRODUCTION. A contradicting value throws ConfigurationException |
.baseUrl(String) | per environment | Test-server override. https:// only; http:// only for localhost / 127.0.0.1 |
.timeout(Duration) | 30 seconds | Per attempt |
.maxRetries(int) | 2 | 0–5; retries after the first attempt |
.webhookSecret(String) | — | For client.webhooks() |
.publishableKey(String) | — | For checkout().getPlans(...); same environment as the API key |
.language(String) | "en" | "ar" or "en" |
.userAgentSuffix(String) | — | Appended to the SDK User-Agent |
.logger(...) | none | (level, message, fields) callback |
.httpClient(HttpClient) | built-in | Your own client; build it with HttpClient.Redirect.NEVER |
Create a checkout session
Use BigDecimal from a string (new BigDecimal("1302.10")), never the double constructor. The amounts below add up: items 899.00 + 135.00 + 120.00 = 1154.00, plus tax 173.10, plus shipping 25.00, minus discount 50.00 = 1302.10. The Item builder computes each line's totalAmount when you leave it unset.
import com.qasimha.sdk.exception.DuplicateOrderReferenceException;
import com.qasimha.sdk.exception.OrderDeclinedException;
import com.qasimha.sdk.model.*;
import java.math.BigDecimal;
CreateSessionRequest request = CreateSessionRequest.builder()
.orderReferenceId("ORDER-12345") // your own id, unique per order
.totalAmount(new BigDecimal("1302.10"))
.taxAmount(new BigDecimal("173.10"))
.shippingAmount(new BigDecimal("25.00"))
.discountAmount(new BigDecimal("50.00"))
.description("Order #12345")
.addItem(Item.builder()
.referenceId("SKU-1").name("Wireless Headphones").type(Item.TYPE_PHYSICAL)
.sku("SKU-1").quantity(1).unitPrice(new BigDecimal("899.00")).build())
.addItem(Item.builder()
.referenceId("SKU-2").name("USB-C Cable").type(Item.TYPE_PHYSICAL)
.sku("SKU-2").quantity(3).unitPrice(new BigDecimal("45.00")).build())
.addItem(Item.builder()
.referenceId("SKU-3").name("Extended Warranty").type(Item.TYPE_DIGITAL)
.sku("SKU-3").quantity(1).unitPrice(new BigDecimal("120.00")).build())
.consumer(Consumer.builder()
.firstName("Noura").lastName("Al-Shammari")
.phoneNumber("+9665XXXXXXXX").email("[email protected]").build())
.shippingAddress(Address.builder() // required by the API
.firstName("Noura").lastName("Al-Shammari")
.line1("King Fahd Rd").city("Riyadh").countryCode("SA").build())
.merchantUrl(MerchantUrl.builder()
.success("https://store.example/checkout/return?order=12345")
.failure("https://store.example/checkout/failed?order=12345")
.cancel("https://store.example/cart")
.build())
.build();
try {
Session session = client.checkout().createSession(request);
saveQasimhaOrderId("ORDER-12345", session.getOrderId()); // every later call uses this id
redirect(session.getPaymentUrl());
} catch (OrderDeclinedException e) {
showDeclined(); // e.getOrderId() was not approved
} catch (DuplicateOrderReferenceException e) {
redirect(e.getExistingPaymentUrl()); // double submit: reopen the existing checkout
}Defaults: paymentType pay_by_instalments, instalments 4, currency SAR, country SA. The SDK checks the basket before sending, to the halala, and throws ValidationException naming the line:
item.totalAmount = quantity × unitPrice − item.discountAmounttotalAmount = Σ item.totalAmount + taxAmount + shippingAmount − discountAmount
merchantUrl sends the shopper back to success, failure or cancel after the hosted checkout, which expires after two hours. See Checkout flows.
Confirm the order on the return route
The hosted checkout sends the shopper to your merchantUrl with orderId, orderReferenceId and status appended. These parameters are not signed. Read the order before you mark it paid:
@GetMapping("/checkout/return")
public String checkoutReturn(@RequestParam String orderId) {
Order order = client.orders().get(orderId);
if (OrderStatus.CONFIRMED.equals(order.getStatus())) {
markPaid(order.getOrderReferenceId()); // first instalment paid
return "redirect:/thank-you";
}
return "redirect:/checkout?payment=failed";
}If you only hold your own reference, use client.orders().getByReference("ORDER-12345"). It returns the same Order, matches exactly (case-sensitive), and throws NotFoundException for an unknown reference. See Return URLs.
Handle webhooks
Verify every delivery with the raw request bytes exactly as received. A body that a framework parsed and re-serialised will not verify. Then confirm state with orders().get(...) before you act.
javax.servlet works the same way.
import com.qasimha.sdk.exception.WebhookSignatureException;
import com.qasimha.sdk.model.WebhookEvent;
import jakarta.servlet.http.*;
public class QasimhaWebhookServlet extends HttpServlet {
private final QasimhaClient client = /* the shared client */;
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
byte[] rawBody = request.getInputStream().readAllBytes();
Map<String, String> headers = new HashMap<>();
Collections.list(request.getHeaderNames()).forEach(n -> headers.put(n, request.getHeader(n)));
WebhookEvent event;
try {
event = client.webhooks().constructEvent(rawBody, headers);
} catch (WebhookSignatureException e) {
response.setStatus(400);
return;
}
response.setStatus(200); // acknowledge promptly, process afterwards
enqueue(event); // de-duplicate on event.getId()
}
}Read the bytes from the request rather than binding the body, so no message converter touches it.
@RestController
public class QasimhaWebhookController {
private final QasimhaClient client;
public QasimhaWebhookController(QasimhaClient client) {
this.client = client;
}
@PostMapping("/webhooks/qasimha")
public ResponseEntity<Void> receive(
HttpServletRequest request, @RequestHeader Map<String, String> headers) throws IOException {
byte[] rawBody = request.getInputStream().readAllBytes();
try {
WebhookEvent event = client.webhooks().constructEvent(rawBody, headers);
enqueue(event);
return ResponseEntity.ok().build();
} catch (WebhookSignatureException e) {
return ResponseEntity.badRequest().build();
}
}
}On failure, getReason() is MISSING_HEADER, MALFORMED_HEADER, TIMESTAMP_OUTSIDE_TOLERANCE or SIGNATURE_MISMATCH. The tolerance is 300 seconds by default. During a secret rotation, the old and new secrets both verify.
WebhookEvent carries id, type, orderId, orderReferenceId, status, amount, currency, occurredAt and getRaw(). If orderId is empty, confirm with orders().getByReference(event.getOrderReferenceId()). A service that only receives webhooks can verify without a full client: new Webhooks(System.getenv("QASIMHA_WEBHOOK_SECRET")).constructEvent(rawBody, headers). See Webhooks and Webhook signatures.
Fulfil, refund and cancel
// on shipment: omit shippedAmount to fulfil everything outstanding
client.orders().fulfill(orderId, FulfillRequest.builder()
.shippedAmount(new BigDecimal("1200.00"))
.shippingCompany("Aramex").trackingNumber("ARX123").build());
// after fulfilment; order.getRefundableAmount() is what can still be refunded
client.orders().refund(orderId, RefundRequest.of(new BigDecimal("300.00"), "Customer return"));
// voids an order that has not been fulfilled
client.orders().cancel(orderId);capture(orderId) is only for New / Approved orders. A hosted-checkout order is already Confirmed, and capture answers 400 INVALID_STATUS. See Order lifecycle.
Quote plans with the publishable key
checkout().getPlans(...) returns the plans your policy offers for an amount. It sends the publishable key (X-Public-Key) only, never the secret key.
import com.qasimha.sdk.GetPlansOptions;
import com.qasimha.sdk.model.Plan;
import com.qasimha.sdk.model.Plans;
Plans quote = client.checkout().getPlans(new BigDecimal("1200.00"),
GetPlansOptions.defaults().language("ar"));
if (quote.isOffered()) { // false outside [minAmount, maxAmount] or with no active policy
for (Plan plan : quote.getPlans()) {
render(plan); // getInstalments(), getInstalmentAmount(), getFeesIncludingVat(), getTotalPayable()
}
}Without a publishable key on the client or in GetPlansOptions, the call throws ConfigurationException before any request. The platform caches a quote for one minute. See Plans widget.
Errors
Every failure is an unchecked QasimhaException with getCode(), getHttpStatus(), getRequestId(), getErrors() (field errors) and getRawEnvelope().
| Exception | When |
|---|---|
ConfigurationException | Client misconfigured, or an invalid argument before sending |
ValidationException | Any 400, or a client-side basket, amount or endpoint check (isClientSide()) |
AuthenticationException | 401: missing, invalid or revoked key (API key, or publishable key on getPlans) |
PermissionException | 403: the key lacks the scope |
NotFoundException | 404 |
DuplicateOrderReferenceException | 409 DUPLICATE_ORDER_REFERENCE; getExistingOrderId(), getExistingPaymentUrl() |
ConflictException | Any other 409 (MERCHANT_SUSPENDED, NOT_OPEN, REQUEST_IN_PROGRESS) |
OrderDeclinedException | 422 ORDER_DECLINED; getOrderId() |
RateLimitException | 429 after retries; getRetryAfterSeconds() |
ServerException | 5xx |
ApiException | Anything else, including a 3xx or a 2xx with success: false |
ConnectionException | DNS, TLS, socket or timeout failure (cause attached) |
WebhookSignatureException | Webhook verification failed; see getReason() |
Retries: connection failures, 429, 502, 503, 504 and 409 REQUEST_IN_PROGRESS, up to maxRetries. Every POST, PUT and DELETE carries a UUID v4 Idempotency-Key; RequestOptions.withIdempotencyKey(...) supplies your own. See Errors and Idempotency.
Logging and security
QasimhaClient.builder()
.apiKey(apiKey)
.logger((level, message, fields) -> log.atLevel(level.name()).log("{} {}", message, fields))
.build();Fields are method, path, status, durationMs, requestId, attempt, code and, on a retry, delayMs. Nothing is logged unless you pass a logger.
- Keep the secret key and webhook secret in a secrets manager. Never send them to a browser or mobile app.
- The SDK never logs keys, webhook secrets, query strings, order references in a by-reference lookup, or customer data.
client.maskedKey()returnssk_live_…AbCd, andtoString()shows masked keys only. - TLS only. Certificate checks cannot be disabled, and redirects are never followed.
- Proxies use the JDK's standard settings (
https.proxyHost/https.proxyPort, orProxySelector.getDefault()). - No card data passes through your server; customers pay on the hosted checkout, so you stay in PCI DSS SAQ-A scope.
Next steps
Updated 3 days ago
