To get started:
Once you are done, you should have an API Client ID and Client Secret. Now you can continue with our tutorials.
Before going live, create your production Appture Pay account and complete the FICA processes required for activation.
Use the API base URL for your environment:
https://sandbox.appturepay.com/api/https://api.appturepay.com/For example, sandbox authentication uses POST https://sandbox.appturepay.com/api/auth/access_token. Use credentials issued in the same environment as your API requests.
For further details, see each Endpoint's document below.
Download our integration collection to explore authentication, transactions, recurring transactions, deliveries and payouts in Postman. It includes saved response examples where available and is generated as part of each release deployment.
Download Integration Collection
To use a collection:
base_url variable to https://sandbox.appturepay.com/api for sandbox testing, without a trailing slash. For production, use https://api.appturepay.com.client_id and client_secret, then authenticate using the appropriate auth/access_token example.access_token and fill in resource variables such as transaction_id before sending requests.Start with a Sandbox Client as described below. Code samples are also available under each endpoint and method in this guide.
The sandbox environment is available at https://sandbox.appturepay.com/. Register there and wait for an administrator to approve and activate your account and load sandbox funds.
After activation:
https://sandbox.appturepay.com/api/ using your sandbox client's credentials.Download the sandbox app for your integration and install it on your Android test device. Sign in with your approved sandbox account and use the sandbox API environment for app-based testing.
Download Appture Pay Sandbox APK
Use the current sandbox testing card details for card payment tests. This reference contains the testing card information; use it when preparing your test cases.
You can run tests and preview the response via this documentation. Open the documentation in the sandbox environment when testing against the sandbox API. To make use of this feature you will need to authenticate by entering your sandbox Client ID and Secret, in the Auth Access Token Endpoint's API Testing section, and authenticating. The access token is saved to the session so you can test each endpoint after authenticating.
If you are having trouble using this Doc, or found bugs/errors, please get in touch:
Follow these steps to make use of the Payment Gateway from an eCommerce type website:
auth/access_token endpointtransaction endpointinclude_once "includes/WebRequest.php";
include_once "includes/AppturePayAPI.php";
// 1. In order to authenticate as a Client, simply instantiate the AppturePayAPI
// class with your client details. The constructor will automatically check if
// we have authenticated previously, and access token details were saved to the
// session, or if it should authenticate. If not authenticated it will do so and
// save the access token to the PHP session.
$clientId = "{your client id}";
$clientSecret = "{your client secret}";
// get an instance of the AppturePayAPI class
$api = new ApptureLab\AppturePayAPI($clientId, $clientSecret);
// make sure we are successfully authenticated
if( $api->getSession() !== null ) {
// 2. After authenticating we can create the transaction.
// variable for keeping the response
$transactionPostResponse = null;
// values to pass as request body
$data = array(
"client_id" => $clientId,
"transaction_type" => "DB",
"recurring_term" => "1",
"reference" => "TRANS01",
"description" => "Test transaction",
"total" => "999.99"
);
// then make the call to the transaction/{id} endpoint using the POST method
$transactionPostResponse = $api->transactionPost($data);
// make sure it was a success
if($transactionPostResponse !== null && $transactionPostResponse["success"]) {
// 3. Now we can redirect the user to the Payment Gateway.
header("Location: https://www.appturepay.com/gateway/?id={$transactionPostResponse['data']['identifier']}");
}
}
...
// 4. Receiving the server-to-server callback request from Appture Pay, after successful payment.
$transactionId = filter_input(INPUT_GET, "id");
$transactionReference = filter_input(INPUT_GET, "reference");
if($transactionId && $transactionReference) {
// Get your local order against which to check the transaction.
$order = array();
// Get the transaction from AppturePay.
// get an instance of the AppturePayAPI class
$api = new ApptureLab\AppturePayAPI($clientId, $clientSecret);
// make sure we are successfully authenticated
if( $api->getSession() !== null ) {
// then make the call to the transaction/{id} endpoint using the GET method
$transactionGetResponse = $api->transactionGetSpecific($transactionId);
// make sure it was a success
if($transactionGetResponse !== null && $transactionGetResponse["success"]) {
// Check the transaction's status and also that its total matches
// with your local order.
if($transactionGetResponse["data"]["status"] === "SUCCESS") {
// Do something specific on success, ie update redeemed voucher
// codes, send success email etc.
}
// Update your local order to indicate the status of the payment.
}
}
}
If a transaction request times out, the transaction may already have been created. The optional Idempotency-Key header on POST /transaction lets you retry the same creation request without creating another transaction.
auth/access_token.Idempotency-Key header. Reuse the same attempt for concurrent requests, timeouts and retries.data.identifier against the attempt and order.GET /transaction/{identifier} and your callback handling.Keys are case-sensitive and must contain 1–128 visible ASCII characters without spaces. Send a single header value. A UUID saved with the attempt or an identifier such as sale-10482-payment-1 is suitable. Use a new key only when intentionally creating another transaction.
Keep the original request details unchanged on retries. JSON property order does not matter, and ordinary decimal encodings such as 125, 125.0 and "125.00" compare equally. Do not assume that omitting a field is equivalent to explicitly sending its default value.
POST https://api.appturepay.com/transaction
Authorization: Bearer ACCESS_TOKEN
Content-Type: application/json
Idempotency-Key: sale-10482-payment-1
{
"client_id": "YOUR_CLIENT_ID",
"reference": "SALE-10482",
"description": "In-store purchase",
"transaction_type": "DB",
"day_of_debit": 1,
"total": "125.00",
"gateway": "DEFAULT"
}
// Run on your backend, using an access token from auth/access_token.
$accessToken = "{access token}";
// Save this attempt in your database BEFORE sending its first request.
// For retries, load the SAME key and details instead of creating a new attempt.
$attempt = array(
"key" => "sale-10482-payment-1",
"data" => array(
"client_id" => "{your client id}",
"reference" => "SALE-10482",
"description" => "In-store purchase",
"transaction_type" => "DB",
"day_of_debit" => 1,
"total" => "125.00",
"gateway" => "DEFAULT",
),
);
$request = curl_init("https://api.appturepay.com/transaction");
curl_setopt_array($request, array(
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => array(
"Authorization: Bearer " . $accessToken,
"Content-Type: application/json",
"Idempotency-Key: " . $attempt["key"],
),
CURLOPT_POSTFIELDS => json_encode($attempt["data"]),
));
$rawResponse = curl_exec($request);
$status = curl_getinfo($request, CURLINFO_HTTP_CODE);
$transportError = curl_error($request);
curl_close($request);
if ($rawResponse === false) {
// Creation may have committed. Keep the saved attempt for a retry.
throw new RuntimeException("Creation result unknown: " . $transportError);
}
$result = json_decode($rawResponse, true);
if ($status !== 200 || ($result["success"] ?? false) !== true) {
// idempotency_request_in_progress: respect Retry-After, then retry unchanged.
// idempotency_key_conflict: reconcile the saved attempt and original details.
// Do not automatically generate a new key to bypass either conflict.
throw new RuntimeException("Check the saved payment attempt: " . $rawResponse);
}
$identifier = $result["data"]["identifier"];
// Save the identifier against the attempt and order before continuing checkout.
// Use GET /transaction/{identifier} for current status before marking an order paid.
| Result | What to do |
|---|---|
HTTP 200, Idempotency-Replayed: false | The transaction was created. Save its identifier. |
HTTP 200, Idempotency-Replayed: true | The original successful creation response was returned. Reuse its identifier; another transaction was not created. |
HTTP 409, idempotency_key_conflict | The key was already used with different accepted details. Check the original attempt instead of automatically generating a new key. |
HTTP 409, idempotency_request_in_progress | Respect Retry-After (currently 2 seconds), then retry with the same key and details. |
HTTP 400, invalid_idempotency_key | Send one valid, nonempty header value. |
| Timeout, lost response or uncertain server failure | Keep the saved attempt. Retry unchanged in the same client, merchant, mode and environment context. |
GET /transaction/{identifier} to check its current status. Replaying creation does not collect payment again or confirm that payment succeeded.Keys are scoped to the authenticated OAuth client, actor/user context, mode and sandbox/live environment. POS-mode keys also belong to their POS session. Refreshing a token within the same context keeps its key scope; switching clients, environments or POS sessions can create a separate scope. Reconcile an existing attempt before retrying from another context.
Authentication, permissions and creation validation still apply to retries. A validation or creation failure before commit does not save a successful result, so a corrected request can reuse its key. Successful keys have no automatic expiry.
Requests without Idempotency-Key retain the existing creation behaviour without duplicate prevention. This header applies to transaction creation; payment preparation, collection and callback processing retain their existing rules.
Configure your API client's callback_uri for live payments and sandbox_callback_uri for sandbox payments. Use a publicly reachable HTTPS endpoint that accepts server-to-server requests without a browser login. The browser's redirect URL is a separate setting.
Transaction result callbacks use HTTP GET, with URL-encoded query parameters and no JSON request body. Existing query parameters on your callback URL are preserved. For example:
GET /payments/callback?type=DB&action=DEBIT&id=0123456789abcdef0123456789abcdef&reference=SALE-10482&status=SUCCESS&message=Payment+completed HTTP/1.1
Host: merchant.example.com
| Query parameter | Meaning |
|---|---|
type | Transaction type, such as DB (debit), PA (preauthorization), or EFT. Recurring debit notifications use RECURRING_DB. This is separate from the selected gateway. |
action | DEBIT on card/app result callbacks; EFT on the EFT result path. Recurring configuration notifications use a different event format. |
id | The transaction's public identifier, used for GET /transaction/{identifier}. |
reference | Your reference saved when creating the transaction. |
status | The result reported when the notification was created. Fetch the transaction for its current status. |
message | Human-readable status information. URL-decode it; do not use its wording as a machine-readable status. |
recurring_transaction_id | Included by recurring/card/app paths when applicable; may be absent for a once-off payment or EFT callback. |
active | Recurring notifications additionally include the recurring instruction's active flag. |
The transaction callback does not carry a verified amount or a complete transaction object. Recurring configuration updates are distinct: type=RECURRING_DB, action=UPDATED, with recurring_transaction_id, reference, description and active; a transaction id may be absent.
Merchant callbacks currently have no HMAC signature, signing secret, or Authorization header. Treat every callback parameter as untrusted input. Payment-provider webhook verification performed inside Appture Pay does not add a signature to the callback delivered to your server.
id as a public identifier and find the saved payment attempt in your backend. Reject identifiers unrelated to your orders.GET /transaction/{identifier} with Authorization: Bearer {access_token} in the same environment.data.identifier, data.client_id, data.reference and data.total with your saved attempt. Compare amounts in integer cents.SUCCESS. A successful preauthorization (PA) still requires capture; do not treat it as a captured debit.Keep client secrets and access tokens on your server. A redirect, app handoff, callback query string, or HTTP 200 from a lookup alone does not prove payment. Check the API response's success flag and transaction fields. The POS payment confirmation example describes the same reconciliation flow.
Return HTTP 200 OK once the notification has been handled or durably accepted for processing. The delivery worker requires exactly 200; redirects and other status codes count as failures. It does not follow redirects. If you acknowledge before verification, persist the job and retry failed lookups internally.
Failed deliveries retry at one-minute intervals for the first ten retries, ten-minute intervals for the next six, and hourly for the final twenty-four: one initial attempt plus up to forty retries. Handle duplicate and delayed notifications. Do not assume every cancellation, failure, or abandoned checkout generates a callback; reconcile unresolved attempts through authenticated lookup or polling.
| Value | Meaning and handling |
|---|---|
OPEN | Created and unresolved. Payment is not confirmed; it may still be in progress. |
SUCCESS | Successful transaction result. Verify ownership, reference, amount and transaction type before fulfilment. |
FAILED | Payment failed, for example a declined or unverified payment. Do not fulfil. |
ERROR | Gateway or processing error. Do not fulfil; reconcile before starting another payment. |
USER_CANCELLED | User cancellation. This is the exact cancellation value, rather than CANCELLED. It is not a refund or proof that an external payment attempt was voided. |
CLOSED | Declared legacy status for a transaction closed and replaced. Do not treat it as success or as an expiry status. |
| Expiry | There is currently no EXPIRED transaction status or automatic transaction expiry contract. An abandoned checkout may remain OPEN. Provider checkout/session expiry does not define a separate transaction status. |
These values describe the transaction's status. POS collection_state (for example READY, DISPATCHED or CANCELLED) and webhook delivery status are separate fields. Always re-read the transaction when reconciling; a callback is a snapshot, and a cancellation can be followed by an explicitly resumed payment flow.
Save each order's payment attempt, reference, public transaction identifier, client ID, expected amount and idempotency key on your backend. Examples below show paths relative to your API base URL: https://sandbox.appturepay.com/api/ for sandbox and https://api.appturepay.com/ for production. Use an access token issued in that environment.
The direct lookup is GET /transaction/{identifier}, authenticated with Authorization: Bearer {access_token}. A confidential API client can read its own transactions. Use the saved public identifier for payment reconciliation; a merchant reference is not an identifier and is not guaranteed unique.
Reference searches use the transaction list endpoint's exact-match filter, filter[]=reference=SALE-10482. The list endpoint requires owner scope; a confidential token alone does not grant list access. With an authorized owner token, for example:
GET /transaction?filter%5B%5D=reference%3DSALE-10482&limit=25&page=0 HTTP/1.1
Authorization: Bearer {owner_access_token}
Read data.list, inspect all matching attempts and follow zero-based pagination. Owner searches can include multiple clients' transactions: verify each result's client_id, reference, amount and environment. Use filterAnd[] when combining exact conditions with AND; multiple filter[] entries use OR. There is no separate /transaction/reference/{reference} endpoint or direct ?reference=... lookup contract.
For a backend using only client credentials, keep your own reference-to-identifier mapping. If creation times out before you receive an identifier, replay the original creation request with its saved idempotency key.
Cancellation uses the transaction update endpoint with a sparse JSON body:
PUT /transaction/{identifier} HTTP/1.1
Authorization: Bearer {authorized_user_or_pos_access_token}
Content-Type: application/json
{"status":"USER_CANCELLED"}
Authorization, current transaction state and collection state still apply. Standard non-POS cancellation requires an authenticated user context; a confidential client-credentials token alone does not grant this update operation. POS cancellation uses the authorized receiving session. Normal updates allow OPEN and USER_CANCELLED; callers cannot mark an order paid by setting SUCCESS. Inspect both the HTTP status and JSON success, then fetch the transaction again to confirm the result.
For POS collection, cancellation is allowed before collection while the transaction is OPEN, the collection is READY, the caller owns the receiving merchant/session and no external payment attempt has started. A dispatched or uncertain payment can return HTTP 409 payment_confirmation_required; reconcile its result before trying to cancel or starting another attempt. Other POS ownership/session restrictions also apply.
USER_CANCELLED records cancellation in Appture Pay; it does not promise a provider void, refund or permanent prevention of later payment. There is no general transaction expiry endpoint, expiry timestamp input, or EXPIRED status. Enforce your order's checkout deadline in your application and reconcile outstanding payments, including late successes, before deciding whether to fulfil or arrange a refund.
DELETE /transaction/{identifier} permanently deletes an eligible OPEN transaction under the applicable owner permissions and collection guards. Deletion is not a cancellation or expiry mechanism. Preauthorization capture/reversal operations also have their own rules and are not a general cancellation API.
The existing Idempotent Transaction Creation tutorial covers request examples, key format, response headers and conflicts. Use Idempotency-Key on POST /transaction, save it before sending, and retry with the same accepted details in the same client, merchant, mode and environment context after a timeout.
Idempotency-Replayed: true. Fetch the identifier separately for current status.idempotency_request_in_progress: respect Retry-After and retry unchanged.idempotency_key_conflict: reconcile the saved attempt and original details; do not automatically create another key.total is an amount in rand with two decimal places, not cents: 123.45 means R123.45. Transaction creation declares and validates a common minimum of R10.00. The current API defines no separate per-method maximum transaction amount.
| Payment method | Gateway | API creation minimum | Maximum |
|---|---|---|---|
| Online card | DEFAULT | R10.00 | No maximum defined by this API; provider/merchant limits may apply. |
| Instant EFT | EFTSECURE | R10.00 | No maximum defined by this API; provider/bank limits may apply. |
| Appture Pay wallet | APPTURE | R10.00 | No maximum defined by this API; available wallet funds and account rules apply. |
| Phone tap / QR collection | APPTAP | R10.00 | No maximum defined by this API; available wallet funds and account rules apply. |
| Card tap | HALO | R10.00 | No maximum defined by this API; device/provider/merchant limits may apply. |
An undefined maximum does not mean an unlimited payment is accepted. Confirm the applicable provider and merchant limits before enabling a payment method, and handle preparation and payment failures. The common creation minimum is not a guarantee of provider acceptance. For verification and exact status values, see Callbacks and Transaction Statuses.
Before requesting a delivery quotation, you will need to add an Address, for the pickup point, to your Appture Pay account.
Follow these steps in order to get a delivery quotation:
delivery/check_postal_code endpointdelivery endpoint and keep it's iddelivery/{id}/quote endpoint using the id received previouslydelivery/{id} endpointinclude_once "includes/WebRequest.php";
include_once "includes/AppturePayAPI.php";
$clientId = "{your client id}";
$clientSecret = "{your client secret}";
// get an instance of the AppturePayAPI class
$api = new ApptureLab\AppturePayAPI($clientId, $clientSecret);
// First, create a delivery.
// variable for keeping the response
$deliveryPostResponse = null;
// values to pass as request body
$data = array(
"delivery_courier_checked" => 0,
"delivery_contact_name" => "Test",
"delivery_contact_number_1" => "1231231234",
"delivery_street" => "Street Ave",
"delivery_suburb" => "Suburb",
"delivery_city" => "City",
"delivery_province" => "Gauteng",
"delivery_country" => "South Africa",
"delivery_postal_code" => "1234"
);
// make sure we are successfully authenticated
if( $api->getSession() !== null ) {
// then make the call to the delivery/{id} endpoint using the GET method
$deliveryPostResponse = $api->deliveryPost($data);
// make sure the post succeeded
if($deliveryPostResponse !== null && $deliveryPostResponse["success"]) {
// Next we can call the quote endpoint for this delivery.
// variable for keeping the response
$deliveryPostQuoteResponse = null;
// delivery id
$id = $deliveryPostResponse["data"]["id"];
// values to pass as request body
$data = array(
"value" => "199",
"products" => array(
array(
"quantity" => 1, // integer Y number of pieces
"description" => "String with max length of 30", // string[30] N freight description
"height" => 100, // integer Y dimension 1 in millimeters
"width" => 250, // integer Y dimension 2 in millimeters
"length" => 100, // integer Y dimension 3 in millimeters
"weight" => 1.0 // float Y mass in kilograms
)
),
"insurance" => 1
);
// then make the call to the delivery/{id}/quote endpoint using the POST method
$deliveryPostQuoteResponse = $api->deliveryPostQuote($id, $data);
// make sure the post succeeded
if($deliveryPostQuoteResponse !== null && $deliveryPostQuoteResponse["success"]) {
/*
Here you may give the user a list of rates/services, as quoted, in
order for them to select their own preferred service.
*/
// Finally we can select the preferred service for the delivery quote.
// variable for keeping the response
$deliveryPutQuoteResponse = null;
// values to pass as request body
$data = array(
// we will select the first service in the list of services/rates received
"service" => $deliveryPostQuoteResponse["data"]["rates"][0]["service"]
);
// then make the call to the delivery/{id}/quote endpoint using the PUT method
$deliveryPutQuoteResponse = $api->deliveryPutQuote($id, $data);
var_dump($deliveryPutQuoteResponse);
}
}
}
After completing the steps for creating a quotation and selecting a service option, the delivery can be dispatched. On success, the quoted amount for the selected service option will be deducted from your wallet.
delivery/{id}/dispatch endpointinclude_once "includes/WebRequest.php";
include_once "includes/AppturePayAPI.php";
$clientId = "{your client id}";
$clientSecret = "{your client secret}";
// get an instance of the AppturePayAPI class
$api = new ApptureLab\AppturePayAPI($clientId, $clientSecret);
// variable for keeping the response
$deliveryPutDispatchResponse = null;
// delivery id
$id = "{delivery id}";
// make sure we are successfully authenticated
if( $api->getSession() !== null ) {
// make the call to the delivery/{id}/dispatch endpoint using the PUT method
$deliveryPutDispatchResponse = $api->deliveryPutDispatch($id, null);
var_dump($deliveryPutDispatchResponse);
}
After dispatching the delivery, its waybill and label(s) can be downloaded and printed. There is a separate endpoint for each and the waybill or label will be sent back as an inline PDF file which should be saved locally and printed to present it to the courier's official on arrival. In case multiple products were quoted for delivery, multiple labels will exist within the label PDF.
delivery/{id}/waybill endpointdelivery/{id}/label endpointinclude_once "includes/WebRequest.php";
include_once "includes/AppturePayAPI.php";
$clientId = "{your client id}";
$clientSecret = "{your client secret}";
// get an instance of the AppturePayAPI class
$api = new ApptureLab\AppturePayAPI($clientId, $clientSecret);
// variable for keeping the response
$deliveryGetWaybillResponse = null;
$deliveryGetLabelResponse = null;
// delivery id
$id = "{delivery id}";
// make sure we are successfully authenticated
if( $api->getSession() !== null ) {
// make the call to the delivery/{id}/waybill endpoint using the GET method
$deliveryGetWaybillResponse = $api->deliveryGetWaybill($id);
var_dump($deliveryGetWaybillResponse);
// make the call to the delivery/{id}/label endpoint using the GET method
$deliveryGetLabelResponse = $api->deliveryGetLabel($id);
var_dump($deliveryGetLabelResponse);
}
After the courier has picked up the item(s), tracking details will become available.
delivery/{id}/track endpointinclude_once "includes/WebRequest.php";
include_once "includes/AppturePayAPI.php";
$clientId = "{your client id}";
$clientSecret = "{your client secret}";
// get an instance of the AppturePayAPI class
$api = new ApptureLab\AppturePayAPI($clientId, $clientSecret);
// variable for keeping the response
$deliveryGetTrackResponse = null;
// delivery id
$id = "{delivery id}";
// make sure we are successfully authenticated
if( $api->getSession() !== null ) {
// then make the call to the delivery/{id}/track endpoint using the GET method
$deliveryGetTrackResponse = $api->deliveryGetTrack($id);
var_dump($deliveryGetTrackResponse);
}
iKWAZA / Appture Pay · Cloud POS backend · React UI · Merchant app
Create a merchant transaction through the API, then open it in the installed merchant app using a same-device app link or Bluetooth to a nearby Android device. The merchant chooses Tap or QR Scan or Card tap to receive payment. Your POS receives transaction status notifications at its configured callback URL.
| Setting | Example | Purpose |
|---|---|---|
| Callback URI | https://pos.example.com/api/payments/ikwaza/callback | Notifies your backend of transaction status changes. |
| Sandbox callback URI | A publicly reachable test callback URL | Receives sandbox transaction notifications. |
| API base URL | https://api.appturepay.com/ | Use the base URL supplied for your integration environment. |
Keep client secrets and API access tokens on your backend, outside browser storage and VITE_* variables. The app uses its own merchant session. App links carry the transaction identifier; Bluetooth carries that identifier and the receiving app's connection code.
The API client that creates the transaction must belong to the merchant receiving the payment in the app. A successful Bluetooth handoff does not grant another merchant access to that transaction. If a payment is already in progress, check its current status before attempting another payment.
https://sandbox.appturepay.com/api/ for sandbox requests. Install the iKWAZA sandbox APK or Appture Pay sandbox APK on your receiving Android device.| Purpose | Endpoint or app link |
|---|---|
| Authenticate | POST /auth/access_token |
| Create transaction | POST /transaction |
| Verify current status | GET /transaction/{identifier} |
| Open Receive in the installed app | https://app.ikwaza.com/wallet/receive/pos/{identifier} |
Your Test connection button asks your backend to send:
POST https://api.appturepay.com/auth/access_token
Authorization: Basic BASE64(clientId:clientSecret)
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
A successful response supplies access_token, token_type and expires_in. Cache tokens on your backend per merchant and client until shortly before expiry; invalidate them when credentials change. Return only the connection result to the POS browser.
When the cashier requests iKWAZA payment, your backend authenticates the cashier, checks access to the order, loads the authoritative amount, and selects the merchant's credentials. Create or load one payment-attempt record for the order, persisting its idempotency key and request details before calling the API. Reuse that record for concurrent requests and retries.
The following server-side TypeScript example creates a transaction and constructs its app link:
import { Buffer } from "node:buffer";
// Use the API base URL supplied for your integration environment.
const API_URL = "https://api.appturepay.com/";
const APP_URL = "https://app.ikwaza.com/";
type MerchantCredentials = { clientId: string; clientSecret: string };
// Persist these values on your backend BEFORE the first API request.
// Load and reuse the same attempt for retries, including after a timeout.
type PaymentAttempt = {
idempotencyKey: string;
reference: string;
description: string;
totalCents: number;
};
export async function createAppPayment(
credentials: MerchantCredentials,
attempt: PaymentAttempt,
) {
if (!Number.isSafeInteger(attempt.totalCents) || attempt.totalCents < 1000) {
throw new Error("The minimum payment amount is R10.00.");
}
const authResponse = await fetch(`${API_URL}auth/access_token`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(
`${credentials.clientId}:${credentials.clientSecret}`,
).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({ grant_type: "client_credentials" }),
});
const token = await authResponse.json();
if (!authResponse.ok || !token.access_token) {
throw new Error("Unable to authenticate the merchant's API client.");
}
const response = await fetch(`${API_URL}transaction`, {
method: "POST",
headers: {
Authorization: `${token.token_type || "Bearer"} ${token.access_token}`,
"Content-Type": "application/json",
"Idempotency-Key": attempt.idempotencyKey,
},
body: JSON.stringify({
client_id: credentials.clientId,
reference: attempt.reference,
description: attempt.description,
transaction_type: "DB",
day_of_debit: 1,
total: attempt.totalCents / 100,
gateway: "DEFAULT",
}),
});
const result = await response.json();
if (!response.ok || result.success !== true) {
// Preserve the key and original details. Do not create another attempt
// automatically after a timeout, 409, or uncertain server response.
throw new Error(result.error || "Unable to create the payment transaction.");
}
const identifier = result.data?.identifier;
if (typeof identifier !== "string" || !/^[0-9a-f]{32}$/i.test(identifier)) {
throw new Error("The API did not return a valid transaction identifier.");
}
const appUrl = new URL(`wallet/receive/pos/${identifier}`, APP_URL).toString();
// Save the identifier against this attempt before returning it to the POS.
return { identifier, appUrl };
}
DB is a once-off debit payment. total is in rand; 123.45 means R123.45. The current minimum is R10.00.gateway: "DEFAULT" for a transaction to be collected in the app. The merchant selects the payment method in the app.POS-112, and save the original amount and description with the attempt.data.identifier is the public transaction identifier. Use it for the app link and subsequent status lookups.Idempotency-Key header to prevent duplicate transactions on retries. Use one stable key per intended transaction. Keys are case-sensitive, 1–128 visible ASCII characters without spaces. Retrying the same key and details returns the original creation response. A changed request with that key returns HTTP 409 (idempotency_key_conflict). For idempotency_request_in_progress, respect Retry-After and retry the same request. A replay is a creation snapshot; use GET for current status.Implement your own POS endpoint, for example POST /api/orders/112/payments/ikwaza. Save the returned identifier against the merchant, order, expected amount and attempt before returning:
{
"identifier": "PUBLIC_IDENTIFIER",
"appUrl": "https://app.ikwaza.com/wallet/receive/pos/PUBLIC_IDENTIFIER"
}
If your POS already has an identifier for the current attempt, reuse it. A network timeout does not prove creation or payment failed. Preserve the key and reconcile the existing transaction before intentionally creating a new attempt.
Use an app link when the POS browser and receiving app run on the same device, or Bluetooth when the POS browser runs on a nearby device. Both handoffs use the transaction identifier already saved by your backend.
Open the verified app link returned by your backend on the device running the POS page:
https://app.ikwaza.com/wallet/receive/pos/PUBLIC_IDENTIFIER
After creating the transaction, display an Open iKWAZA link for the merchant to tap. The following React example requests payment through your POS backend and displays that link:
import { useState } from "react";
type AppPayment = { identifier: string; appUrl: string };
export function IkwazaPayment({ orderId }: { orderId: string }) {
const [payment, setPayment] = useState<AppPayment | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function requestPayment() {
setBusy(true);
setError("");
try {
const response = await fetch(
`/api/orders/${encodeURIComponent(orderId)}/payments/ikwaza`,
{ method: "POST" },
);
if (!response.ok) throw new Error("Unable to start payment.");
setPayment(await response.json());
} catch (error) {
setError(error instanceof Error ? error.message : "Unable to start payment.");
} finally {
setBusy(false);
}
}
return (
<div>
<button disabled={busy || payment !== null} onClick={requestPayment}>
{busy ? "Creating payment..." : "Request iKWAZA payment"}
</button>
{error && <p role="alert">{error}</p>}
{payment && <a href={payment.appUrl}>Open iKWAZA to receive payment</a>}
</div>
);
}
Protect this endpoint with your POS authentication and request validation. A repeated request for the same payment attempt must return its saved transaction identifier.
For an Android-only alternative, provide an explicit Open Android app directly link:
// Optional Android-only button, after creating and saving the transaction.
const androidAppUrl =
`intent://app.ikwaza.com/wallet/receive/pos/${payment.identifier}` +
"#Intent;scheme=https;package=com.appturelab.ikwaza;" +
`S.browser_fallback_url=${encodeURIComponent(payment.appUrl)};end`;
// Set this on an <a href={androidAppUrl}>Open Android app directly</a> link.
Enable supported-link handling for iKWAZA in the Android app settings and allow the browser to open links in other apps. If a link stays in the browser, check that iKWAZA is installed and these settings are enabled. The merchant must open the transaction in the app to receive payment.
Keep the cloud POS order screen available and refresh its status through your backend when the merchant returns. The app's Done action returns to its own dashboard; this integration does not automatically navigate back to the browser.
For Appture Pay, use https://app.appturepay.com/wallet/receive/pos/{identifier} and Android package com.appturelab.appturepay.
Serve the sending POS over HTTPS. Both devices need internet access for API requests and payment processing. Choose a transport supported by the receiving phone and sending browser:
| Connection | Sending POS | Receiving phone | OS pairing |
|---|---|---|---|
| Bluetooth LE | Chrome/Edge with navigator.bluetooth and a BLE adapter | Android with BLE advertising support | Not required |
| Bluetooth Classic | Desktop Chrome 117+ or compatible Edge with navigator.serial and Bluetooth Classic | Android 6+ with Bluetooth Classic; BLE advertising is not required | Pair once in phone/computer Bluetooth settings |
Check window.isSecureContext and the selected browser API before offering that transport. For Classic, pair the devices in their Bluetooth settings first (keep the phone's Bluetooth settings open while discovering it), confirm the pairing prompts, then return to the receiving app. The app starts a secure RFCOMM service when Bluetooth receiving is enabled on its Bluetooth settings page. Return from Android's system Bluetooth settings to the app before sending.
The Bluetooth settings page displays Bluetooth Classic when BLE receiving is unavailable, including Android 6 phones without BLE advertising. Select Classic in your POS. Phones supporting both transports accept either through the same receiving switch. See the Chrome Bluetooth RFCOMM documentation for browser requirements. The browser must allow access to the selected custom Bluetooth service; a browser exposing Web Serial without Bluetooth RFCOMM support cannot use this connection.
After each payment, the app's Done action returns the merchant to the POS dashboard. Wait for the Bluetooth icon to show ready before sending the next sale. Receiving stays enabled for that POS session, and the connection code stays the same until receiving is switched off or the POS session ends. Tap the icon to reopen Bluetooth settings and switch receiving off there. Opening settings or returning to the dashboard keeps the same connection and code.
Load the browser helper once per page. For each new sale, create or load that order's payment attempt through your backend and send its saved identifier. Keep the same page open: starting a new sale must not reload the helper or call forgetReceiver(). The helper reuses the selected phone, including after connection failures or a change of connection code.
To choose another phone, offer a Change Bluetooth device action that calls PosBluetoothHandoff.forgetReceiver(). The next send opens the chooser. Reloading the page also starts a new device selection. The browser controls device access permissions; an initial selection is required.
Transaction acceptance pauses while a payment screen is open. Between sales, you can receive on the Bluetooth settings page or POS dashboard while the app remains in the foreground. Receiving stops when the app goes into the background or the phone locks. After returning to the app, wait for the dashboard to show ready before sending.
Download the JavaScript Bluetooth handoff helper and serve it with your POS page. Connect the following example to the payment-attempt response from your backend by calling setPaymentAttempt(attempt). This function only updates the selected attempt; it does not create or send a transaction. Neither API credentials nor bearer tokens are sent over Bluetooth.
<label for="bluetooth-transport">Bluetooth connection</label>
<select id="bluetooth-transport"><option value="ble">Bluetooth LE</option><option value="classic">Bluetooth Classic (paired phone / Android 6)</option></select>
<label for="connection-code">Connection code from the receiving app</label>
<input id="connection-code" inputmode="numeric" maxlength="6" autocomplete="off">
<button id="send-bluetooth" type="button">Send over Bluetooth</button>
<button id="change-device" type="button">Change Bluetooth device</button>
<p id="handoff-status" role="status" aria-live="polite"></p>
<script src="pos_bluetooth.js"></script>
<script>
const sendButton = document.getElementById('send-bluetooth');
const changeDeviceButton = document.getElementById('change-device');
const connectionCode = document.getElementById('connection-code');
const statusMessage = document.getElementById('handoff-status');
let paymentAttempt = null;
let sending = false;
// Call after your backend creates or restores the current order's attempt.
// The backend response contains its saved public transaction identifier.
function setPaymentAttempt(attempt) {
if (sending) throw new Error('Wait for the current handoff to finish.');
paymentAttempt = attempt;
}
sendButton.addEventListener('click', async () => {
if (sending) return;
if (!paymentAttempt || !paymentAttempt.identifier) {
statusMessage.textContent = 'Create or load the payment request first.';
return;
}
sending = true;
sendButton.disabled = changeDeviceButton.disabled = document.getElementById('bluetooth-transport').disabled = true;
statusMessage.textContent = 'Connecting to the receiving app...';
try {
// Call directly from this click; do not await another request first.
if (document.getElementById('bluetooth-transport').value === 'classic') {
await PosBluetoothHandoff.sendClassic(paymentAttempt.identifier, connectionCode.value.trim());
} else {
await PosBluetoothHandoff.send(paymentAttempt.identifier, connectionCode.value.trim());
}
statusMessage.textContent = 'Sent to the app. Waiting for verified payment status.';
} catch (error) {
statusMessage.textContent = error.message
+ ' Check this transaction through your backend before retrying the same sale.';
} finally {
sending = false;
sendButton.disabled = changeDeviceButton.disabled = document.getElementById('bluetooth-transport').disabled = false;
}
});
changeDeviceButton.addEventListener('click', () => {
if (sending) return;
PosBluetoothHandoff.forgetReceiver();
statusMessage.textContent = 'The next send will ask you to select a device.';
});
</script>
The helper retains the selected phone after a connection error. For Bluetooth LE, it retries transient connection setup failures up to three times within a 45-second transfer timeout. Classic opens the selected paired service once per send and waits for the app acknowledgement. It does not automatically resend transaction data after a write starts. After a timeout or disconnection, check the same transaction through your backend before retrying, and reuse its identifier and payment attempt.
| Symptom | What to check |
|---|---|
| The phone does not appear | Unlock the phone, open Bluetooth settings from its POS dashboard, turn on receiving and wait for the brand-colored Bluetooth icon. For LE, the chooser displays devices advertising the POS service. For Classic, confirm OS pairing, select Classic, and use a desktop browser with Bluetooth Web Serial support. Allow requested Bluetooth permissions and check that the sending browser supports the selected transport. |
| The browser asks to select the phone for every sale | Keep the POS page and helper loaded between sales. Call forgetReceiver() only when the merchant explicitly changes devices. |
| Connection timeout | Keep the devices nearby and return the phone to its ready POS dashboard. After the transfer fails, check the existing transaction before retrying it. If the remembered device remains unreachable after restarting the app or Bluetooth, use Change Bluetooth device to select it again. |
| Invalid connection code | Use the current six-digit code shown in the app, including leading zeros. Switching receiving off or ending the POS session requires a new code. |
| The app rejects the merchant | Confirm that the API client creating the sale and the app's receiving merchant belong to the same account and API environment. Changing the Bluetooth device does not change transaction ownership. |
Native POS clients can use BLE GATT or a secure paired RFCOMM socket:
| Field | Value |
|---|---|
| Classic RFCOMM service UUID | 9d421003-8c21-4a6f-9a6a-9bb0d02f8310 (POS Payments). Connect using a secure paired socket. |
| Primary service UUID (BLE) | 9d421001-8c21-4a6f-9a6a-9bb0d02f8310 |
| Write characteristic UUID | 9d421002-8c21-4a6f-9a6a-9bb0d02f8310 |
| Frame | POS1 CODE IDENTIFIER\n, where CODE is six digits, IDENTIFIER is the 32-character public transaction identifier, and \n is one newline byte (0x0A). |
| Encoding and transfer | BLE: ASCII, sequential chunks of at most 20 bytes. Use writes with response and wait for each acknowledgement before sending the next chunk. |
Classic sends the same 45-byte ASCII frame over RFCOMM in one write, then reads a newline-terminated ASCII acknowledgement. Success is exactly OK IDENTIFIER\n with the same identifier; BUSY\n means the app is not ready, and INVALID\n means the code or frame was rejected. The phone accepts one frame per connection and closes it after replying. Send within 15 seconds of connecting. Reopen the remembered port for each subsequent sale; do not request a new chooser each time. No API credentials are sent over this connection.
The helper waits up to 45 seconds for Classic connection/receipt and closes the port afterward. It does not automatically resend Classic writes. A missing acknowledgement leaves the outcome uncertain: check the existing transaction status and the receiving phone before manually retrying the same identifier. OS pairing remains saved unless removed in Bluetooth settings; page reloads can require selecting the paired service again.
A successful Bluetooth transfer confirms receipt of the identifier. It does not confirm merchant authorization or payment. The app checks transaction ownership and status through the API. Keep the cloud order pending until your backend verifies payment through the API; a transport acknowledgement alone must not mark an order paid.
| Receive option | Behavior |
|---|---|
| Tap or QR Scan | The customer pays from their wallet by tapping their phone or scanning the displayed QR code. Phone tap requires NFC support with NFC enabled. QR payments work on devices without NFC. |
| Card tap | The customer taps their bank card on a supported Android 10+ NFC device. Card acceptance must be enabled for the merchant. |
Your POS creates the transaction, opens the app link, and confirms the result through the API. The app handles the merchant's payment interaction and authorization.
If the app closes or a request times out, check the transaction's current status before starting another payment. Reopen the same transaction link to resume or check its progress.
Transaction status notifications are sent to the API client's configured callback URL using GET, with parameters such as:
https://pos.example.com/api/payments/ikwaza/callback
?id=PUBLIC_IDENTIFIER&reference=POS-112&status=SUCCESS&...
For each notification:
id and load the merchant from that saved record.GET https://api.appturepay.com/transaction/PUBLIC_IDENTIFIER
Authorization: Bearer ACCESS_TOKEN
identifier, client_id, reference and total to the saved attempt. Compare amounts in integer cents.SUCCESS. Apply the update atomically so duplicate or concurrent notifications cannot fulfill an order twice.OPEN transaction is still awaiting confirmation. An older notification must not revert an already verified payment.Failed callback deliveries retry at increasing intervals of one minute, ten minutes and then one hour. Use publicly reachable HTTPS endpoints that accept server-to-server requests without a browser login.
Your POS can also poll its own backend while displaying Checking payment…. That backend uses the same authenticated transaction lookup and verification routine, covering delayed callbacks and merchants returning from the app before notification arrives.
Test with an approved sandbox account, a sandbox API client using https://sandbox.appturepay.com/api/, and the app configured for that environment. Configure a publicly reachable sandbox callback URL before creating test transactions. For card payment tests, use the current sandbox testing card details.