QR Code Payments — business rules

The constraints, limits, and field-level rules that apply to all QR payment flows. Read once, refer back when you hit an error.

The rules below apply to every QR payment flow — static, dynamic, bill payment, USSD via QR, in-app. They govern how you call the API, what the platform validates, and how transactions settle.

This page consolidates rules that were previously scattered across half a dozen business-rules documents. If you only read one Merchant / PSP reference page, read this one before going live.


Request rules

RuleDetailWhere it applies
HTTPS requiredPlain HTTP is rejected.All endpoints
Basic Auth or JWT BearerEvery authenticated request must include credentials. See Authentication.All non-/public/** endpoints
Content-Type: application/jsonRequired on requests with a body.All POST and PUT endpoints
Username conventionmerchant-{id} for merchants, psp-{id} for PSPs. The {id} is shown on the Portal home page.Basic Auth username
merchantReference is unique per merchant, permanentlyEnforced by UNIQUE (merchantReference, merchantId) in the database. See Idempotency.POST /code/create, PUT /code/{code}/amount

Amount rules

RuleDetail
Currency is tied to the merchantSet once at merchant creation. The amount is always in the merchant's configured currency (typically ZAR for South African merchants).
Variable amount = 0Set amount: 0 on POST /code/create to allow the customer to enter the amount on their phone.
Fixed amount cannot be modified by the customerIf you create a code with a non-zero amount, the customer sees and confirms exactly that value.
Two decimal placesThe platform rounds amounts to 2 decimal places (rounding mode: DOWN).
Partial paymentIf requestPartialPayment: true is set on the code, the customer can adjust the amount before paying.

Code lifetime rules

RuleDetail
Default expiry30 minutes from creation, if expiryDate is omitted.
expiryDate: 0Never expires. Use for printed static QRs.
expiryDate: -1 (the field default at the DTO level)Treated as "use default 30 minutes."
useOnce: true (default)The code transitions to Used after one successful payment.
useOnce: falseThe code stays in Available after each payment and can be re-paid.
Concurrent paymentsA code can only be in one Locked state at a time. A second simultaneous scan returns 443 CODE_LOCKED.

Notification rules

These echo what's on Webhooks but are worth recapping here:

RuleDetail
Notification URL set in PortalEach merchant has at most one HTTP notification URL.
HTTPS required for production notificationsSandbox accepts HTTP for testing, production requires HTTPS.
Allowed ports80 (sandbox only), 443, 8080 (sandbox only), 8443.
45-second acknowledgementYour handler must return HTTP 200 within 45 seconds or the transaction is reversed.
Encrypted payloadAES/CBC/PKCS5 with a notification key generated in the Portal. See Signing and verifying webhooks.
HTTP and polling are mutually exclusiveIf your merchant has an HTTP notification URL, queryRef returns 406 Not Acceptable. Pick one.

Sub-merchant and terminal overrides

If your merchant is set up as a super merchant (an aggregator processing on behalf of many sub-merchants):

FieldEffect
subMerchantNameDisplayed on the consumer's bank statement (if supported by the acquirer).
terminalIdOverride the default terminal ID for this transaction. Up to 40 characters.
mccOverride the merchant category code. 4-digit numeric.

These override the merchant-level defaults set at onboarding. Use sparingly — incorrect MCCs or terminal IDs can cause settlement reconciliation issues with your acquirer.


Limit baskets — AIRTIME and AIRTIME_BUNDLE

If your code includes cartItems with itemType: "AIRTIME" or "AIRTIME_BUNDLE" and a destination MSISDN, the platform applies velocity limits to prevent fraud. The defaults:

Item typeDaily limitMonthly limit
AIRTIMER1 000R2 000
AIRTIME_BUNDLE (SMS / data bundles)R2 000R5 000

Limits are enforced per destination MSISDN across the platform — not per merchant — to make fraud rings harder to operate.

If the limit is exceeded, the transaction surfaces LIMIT_FAILED on the webhook notification (END_LIMIT_FAILED as TxState). See Transaction states.

📘

Limit checking must be explicitly enabled for your merchant. Without that flag, sending a basket with airtime items returns 447 INVALID_BASKET. Talk to onboarding if you're integrating airtime sales.


RRN2 (secondary reference number)

If your downstream system needs a secondary 12-character RRN beyond the bank's RRN, supply rrn2 on POST /code/create:

RuleDetail
LengthExactly 12 characters
PaddingLeft-fill with zeros if your value is shorter
Example"rrn2": "000123456789"

The platform passes rrn2 through to the bank message and surfaces it on the notification payload. Most integrators don't need this.


Tip handling

When the customer adds a tip at checkout, the tip is included in the main amount and broken out on the notification payload via tipAmount. The customer sees the total, you reconcile by reading both fields.

See Handling tips for the full flow including requestTip, tipFixed, and tipPercentage on the create-code request.


What's next