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
| Rule | Detail | Where it applies |
|---|---|---|
| HTTPS required | Plain HTTP is rejected. | All endpoints |
| Basic Auth or JWT Bearer | Every authenticated request must include credentials. See Authentication. | All non-/public/** endpoints |
Content-Type: application/json | Required on requests with a body. | All POST and PUT endpoints |
| Username convention | merchant-{id} for merchants, psp-{id} for PSPs. The {id} is shown on the Portal home page. | Basic Auth username |
merchantReference is unique per merchant, permanently | Enforced by UNIQUE (merchantReference, merchantId) in the database. See Idempotency. | POST /code/create, PUT /code/{code}/amount |
Amount rules
| Rule | Detail |
|---|---|
| Currency is tied to the merchant | Set once at merchant creation. The amount is always in the merchant's configured currency (typically ZAR for South African merchants). |
Variable amount = 0 | Set amount: 0 on POST /code/create to allow the customer to enter the amount on their phone. |
| Fixed amount cannot be modified by the customer | If you create a code with a non-zero amount, the customer sees and confirms exactly that value. |
| Two decimal places | The platform rounds amounts to 2 decimal places (rounding mode: DOWN). |
| Partial payment | If requestPartialPayment: true is set on the code, the customer can adjust the amount before paying. |
Code lifetime rules
| Rule | Detail |
|---|---|
| Default expiry | 30 minutes from creation, if expiryDate is omitted. |
expiryDate: 0 | Never 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: false | The code stays in Available after each payment and can be re-paid. |
| Concurrent payments | A 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:
| Rule | Detail |
|---|---|
| Notification URL set in Portal | Each merchant has at most one HTTP notification URL. |
| HTTPS required for production notifications | Sandbox accepts HTTP for testing, production requires HTTPS. |
| Allowed ports | 80 (sandbox only), 443, 8080 (sandbox only), 8443. |
| 45-second acknowledgement | Your handler must return HTTP 200 within 45 seconds or the transaction is reversed. |
| Encrypted payload | AES/CBC/PKCS5 with a notification key generated in the Portal. See Signing and verifying webhooks. |
| HTTP and polling are mutually exclusive | If 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):
| Field | Effect |
|---|---|
subMerchantName | Displayed on the consumer's bank statement (if supported by the acquirer). |
terminalId | Override the default terminal ID for this transaction. Up to 40 characters. |
mcc | Override 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 type | Daily limit | Monthly limit |
|---|---|---|
AIRTIME | R1 000 | R2 000 |
AIRTIME_BUNDLE (SMS / data bundles) | R2 000 | R5 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 returns447 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:
| Rule | Detail |
|---|---|
| Length | Exactly 12 characters |
| Padding | Left-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
- Set up your account before integrating → Merchant onboarding
- Authentication and credentials → Authentication
- Errors you might hit and what to do → Errors
- Going live → Going live
Updated 4 days ago
