On this page · Browse sections

Looking for an endpoint? Jump to the Endpoint index (every endpoint, sorted by path), the request and response examples (every endpoint with an example payload and response), the services and components catalogue, or search this file for the path, for example cashsend/initiate.



1. Getting started: a 10-minute overview

1.1 Platform overview

Zakhaa Pay is a South African mobile wallet. Every customer gets a Zakhaa wallet holding rand, identified by a 10-digit Zakhaa ID (shown in the app and encoded in their QR code). With it they can:

Feature What the customer does Where in the code
Send Zakhaa Send money to another Zakhaa wallet, instantly and free of bank fees WithdrawalController → WithdrawalRepository (13.8)
PayShap Pay any South African bank account in seconds PayshapController → PayshapRepository (13.14)
Scan to Pay / Zapper Pay a shop by scanning its QR code ScanToPayController, ZapperController (13.15)
NFC tags Get paid by someone tapping a sticker ScanToPayController NFC actions (13.16)
VAS Buy airtime, data, electricity, gift vouchers, 1Voucher FlashController → VasService (13.9)
CashSend Send cash to any phone number, collected at an ATM CashSendController → CashSendRepository (13.13)
Zakhaa vouchers Turn wallet money into a voucher someone else redeems ZakhaaVoucherController (13.12)
Top up Card / Apple Pay / Google Pay / Samsung Pay, cash at a till (Pay@), cash at a cash device, bank transfer 13.11
Cash withdrawal Withdraw notes at a PaySolution cash device Features/CashDevices (13.10)

Businesses get a separate business wallet and can accept online payments through the Zakhaa Pay Checkout gateway: merchants get API keys, create checkout sessions, receive signed webhooks and can refund (13.1–13.5).

Ops, support and compliance staff use the admin dashboard: user management, KYC review, bank reconciliation, campaigns, the gateway admin, refunds and cash devices.

1.2 Who calls this API, and how they prove who they are

One ASP.NET Core process serves five kinds of caller:

Caller Proof of identity Typical endpoints
Mobile app User JWT and an RSA signature from the bound phone, on every money request (§6.2) /api/withdrawal, /api/payment, /api/flash/*
Admin dashboard Admin JWT from ASP.NET Identity, with mandatory TOTP 2FA and roles (§6.4) /api/user/*, /api/gateway/admin/*, /api/admin/refunds
Partners OAuth client-credentials JWT carrying a scope (§6.5) Pay@, EFT Corp QR lookup, helpdesk, cash devices
Merchants Secret API key sk_test_… / sk_live_… (§6.6) /api/checkout-sessions
Processors Encrypted, signed or tokenised callbacks, always re-verified (§6.9) /api/stp/callback, Didit, CashSend

1.3 The seven rules that keep everything consistent

  1. The ledger is the truth. Every balance change writes a WalletLedgers row (opening, change, closing). Wallet.balance is only a cache of the latest closing balance (§7).
  2. Money in is a Topups row, money out is a Withdrawal row, whatever the rail. The app's history is built from both, plus the rail tables (§7.1).
  3. Money code locks the wallet (SELECT … FOR UPDATE) and writes the business row and the ledger row in one transaction.
  4. "Strict" means two proofs. App money endpoints need a user JWT and a device signature from the same user.
  5. Every app endpoint checks ownership. A wallet, user or record ID in a request must belong to the caller, otherwise 403 (§6.3).
  6. Two coding styles. Older features are MVC controllers with repositories; newer ones (gateway, refunds, cash devices) are vertical slices in Features/ (§4).
  7. Migrations are applied by hand. Nothing migrates on startup (§3.5).

1.4 Recommended codebase reading order

  1. Program.cs, then Extensions/ServiceExtensions/* (what is registered) and Extensions/PipelineExtensions.cs (request order).
  2. Signing/ (how requests are authenticated) and Signing/CallerExtensions.cs (ownership).
  3. Repositories/WithdrawalRepository.cs (Send Zakhaa: the clearest example of a full money transaction).
  4. Services/Ledger/DbWalletLedger.cs (reserve → finalise → refund, used by most external rails).
  5. Features/Gateway/ (the newest code and the style to copy).

2. Glossary

Term Meaning
Wallet / wallet_id A balance holder. A user has a personal wallet (busid = null) and one wallet per business (busid set).
Zakhaa ID The wallet ID shown to users and encoded in QR codes. Pre-generated into WalletIDs/ZakhaaIDs.
Send Zakhaa Wallet-to-wallet transfer (POST /api/withdrawal, method "Send Zakhaa").
Topup Any credit to a wallet (row in Topups), not only bank top-ups.
Withdrawal Any debit from a wallet (row in Withdrawal), not only cash withdrawals.
Ledger WalletLedgers: append-only history of balance changes.
Strict [AuthorizeStrict]: user JWT + device signature (policy JwtAndDevice).
Device binding Registering the phone's RSA public key against the user (/api/auth/device/bind).
Trust tier New / Growing / Established / Trusted: sets a wallet's daily spending cap (§7.4).
Hold A recent card or EFT deposit that can't be spent yet (§7.5).
STP / Scan to Pay EFT Corp's QR card-payment network. Also used for Apple, Google and Samsung Pay on the checkout page.
XPay The web checkout's Scan to Pay route (/api/stp/initiate/xpay).
VAS Value-added services (airtime, data, electricity, vouchers) bought through Flash.
PayShap South Africa's instant bank payment rail; reached through Zakhaa's PayShap middleware.
Pay@ Retail bill-payment network; customers deposit cash at tills using the wallet's barcode.
Cash device PaySolution cash-in/cash-out machines (/api/cash-devices).
Gateway / checkout session Merchant online payments. A session has a ref like ZP-….
Mode Gateway test (sandbox, no money moves) or live, set by the merchant's key.
Review flag Gateway flag on a session needing a human: LATE, DUPLICATE, WALLET_MISMATCH, MODE_MISMATCH.
SAST South African time (Africa/Johannesburg), used for daily rules.
OPS log A log line starting OPS:: needs a human to follow up.

3. Environments, configuration and deployment

3.1 Environments

Production UAT / QA
Public base URL https://api.wallet.zakhaapay.com/v2 https://wallet-uat.zakhaapay.co.za
Path base (ASPNETCORE_PATHBASE) /v2 /wallet/uat (the reverse proxy maps it)
Port inside the container 5003 5000
ASPNETCORE_ENVIRONMENT Production Development
Config file appsettings.Production.json (in git) appsettings.Development.json (not in git)
Secrets AWS Secrets Manager, loaded at startup (AWSSecrets.GetSecret()) The Config section of appsettings.Development.json
EFT Corp server https://rags.oltio.co.za/rags/ https://ragstest.oltio.co.za/pluto/
Swagger off /swagger
Seed data no SeedInitialDataAsync runs at startup

Health check: GET /healthz → { description, status, version, env }.

3.2 Secrets

Every secret is a property of Models/SecretKeys.cs, read through AWSSecrets.SecretKeys:

  • Production: the whole object is loaded from AWS Secrets Manager when the app starts.
  • UAT / local: it's bound from the Config section of appsettings.Development.json.

To add a secret: add a property to SecretKeys, then add the value to Secrets Manager (production) and to Config (UAT).

Group Names Used by
JWT JWT_SecretKey, JWT_ValidIssuer, JWT_ValidAudience, ExpirationInMinutes, RefreshTokenExpirationDays All token issuing and validation (§6)
Database DB_Host, DB_Port, DB_Name, DB_Username, DB_Pwd, DB_SslMode DatabaseExtensions
Encryption and signing EncryptionKey (AES), RSA_Private_Key, RSA_Public_Key, MIGKeyId EncryptionService, ApiSigningHandler
EFT Corp EFT_Corp_Basic_User, EFT_Corp_Basic_Pass, EFT_Corp_MerchantId, EFT_Corp_Notification_Key, ApplePay_TenantWalletID, GooglePay_TenantWalletID, SamsungPay_TenantWalletID EftCorpClient, callback decryption
Didit (KYC) DiditAPIKey, DiditAppID, DiditWebhookSecret DiditClientService, DiditWebhookRepository
Email Email_Address, Email_Username, Email_Pwd, PasswordResetUrl, LockoutAlertTo EmailSender, admin lockout alerts
AWS S3 BucketName, Region, AccessKey, SecretKey KYC documents, campaign media
Google GeocodingApiKey LocalisationService (area and postal code from GPS)
Tawk.to TawkApiKey SupportController (chat identity hash)
Other FlashAccountNumber, ClamAV_Host, ClamAV_Port Flash, virus scanning

A missing secret may not be detected during application startup; in many cases, the failure occurs when the relevant integration or service first accesses it. EncryptionKey and JWT_SecretKey throw as soon as anything needs them.

Currently tracked in source control: appsettings.Production.json holds the Nedbank sandbox client ID, secret and certificate password, and Certificates/Sandbox.pfx is tracked. The Nedbank integration is commented out, so nothing uses them, but they should be removed (§16.1).

3.3 Non-secret configuration

The full list of settings is in §18. The ones you'll touch most:

Key Purpose
EftCorp:BaseUrl EFT Corp API root (rags in production, pluto in UAT).
EftCorp:RefundWebhookBaseUrl Public base of our per-refund webhook (13.5).
ScanToPayMiddleware:* Zakhaa's EFT Corp middleware URL and paths.
CashSend:CallbackUrl Where TransMS posts CashSend updates.
Otp:AllowTestAccounts Enables the store-review test account's fixed codes (§6.3). true in UAT and production.
Terminal:BootstrapUrl Where unknown POS merchants are fetched from (13.21). Unset = the test service.
CampaignRateLimits:* Push and email throughput for campaigns.

3.4 Deployment

Production (deploy-V2.sh) UAT (uat-deploy.sh)
Target EC2 54.166.72.204, folder wallet-api-v2 UAT box 100.73.213.11, folder wallet-api-uat
Packaging Zips the repo (minus bin, obj, tests, archives, appsettings.Development.json) and uploads it Same idea; builds its own Dockerfile
Run docker compose up -d --build wallet-api: container wallet-api-v2, host network, 800 MB RAM, 0.6 CPU, plus a clamav sidecar docker run on network uat-net

Every deploy deletes and re-extracts the app folder on the server, so nothing stored there survives a deploy.

3.5 Database migrations

Migrations are not applied on startup (the MigrateAsync calls in PipelineExtensions are commented out). To change the schema:

dotnet ef migrations add <Name> --context AppDBContext          # creates Migrations/<timestamp>_<Name>.cs
dotnet ef migrations script <PreviousMigration> <Name> --context AppDBContext -o migrate.sql
# review migrate.sql, run it on UAT, test, then run it on production, then deploy the code

Use ApplicationIdentityDbContext or ApiDbContext for the Identity and OAuth databases. All migrations up to RefundWebhookToken have been applied to production.

Rollback warning: app PINs are upgraded to PBKDF2 when a user logs in (§6.3). Deploying code older than Nov 2026 would lock those users out, so roll forward, not back.


4. Codebase structure and architectural patterns

Program.cs                 composition root (services, then ConfigurePipelineAsync)
Extensions/                DI and pipeline wiring, one file per concern
  ServiceExtensions/       Authentication, Background, Cors, Database, FeatureSlice, Gateway,
                           HttpClient, Identity, Misc (Swagger, rate limiter), Repository
  PipelineExtensions.cs    middleware order, health check, KYC webhook buffering
Controllers/               MVC controllers (style 1)
Repositories/ Interfaces/  data access + business logic for controllers
Services/                  domain services and integration clients
  Gateway/                 checkout payments, refunds, webhook outbox, crypto, rules
  Ledger/                  DbWalletLedger: reserve → finalise / refund
  Processors/              marketing campaign channels (push, SMS, email, in-app, ...)
Features/                  vertical slices (style 2): Gateway, Refunds, CashDevices
Common/                    Result<T>, Error, messaging (ISender, ICommand, IQuery, behaviours)
Signing/                   auth handlers and attributes, outbound request signing
Background/                hosted services and the background task queue
Data/                      DbContexts (AppDBContext split into partial files per domain)
Models/ DTOs/ Mappers/ Enums/ Utilities/
Migrations/                EF Core migrations (AppDBContext; ApiDb/ and ApplicationIdentityDb/ for the others)
tests/WalletApi.Gateway.Tests   xUnit tests
docs/                      (gitignored)

4.1 MVC controllers and repository pattern

A controller action validates input, often checks an OTP, calls a repository, and returns Ok(new ServerResponse(success, message, data)). Business logic lives in Repositories/*Repository.cs (for example WithdrawalRepository.CreateAsync for Send Zakhaa). JSON is Newtonsoft (enums as strings).

Note that many older endpoints return HTTP 200 even on failure, with success: false in the body. Check the body, not only the status.

4.2 Vertical slice architecture

Each feature folder holds its request, handler and endpoint together:

public static class GetFeePlan
{
    public sealed record Query(string Id) : IQuery<Result<object>>;
    public sealed class Handler(AppDBContext db) : IRequestHandler<Query, Result<object>> { ... }
}
public sealed class GetFeePlanEndpoint : IEndpoint
{
    public void Map(IEndpointRouteBuilder app) =>
        app.MapGet(AdminApi.Prefix + "/fee-plans/{id}", async (string id, ISender s, CancellationToken ct) =>
            (await s.Send(new GetFeePlan.Query(id), ct)).ToHttpResult())
           .RequireGatewayAdmin();
}
  • IEndpoint classes are discovered by reflection and mapped by app.MapEndpoints().
  • ISender is an in-house dispatcher (no MediatR). Behaviours in Common/Messaging/Behaviors.cs:
    • run FluentValidation (failures become 422 validation_error);
    • wrap every ICommand in a DB transaction. A command that locks rows, commits in steps or calls outside services implements IManagesOwnTransaction to opt out.
  • Result<T>.ToHttpResult() maps error types to HTTP: Validation and Unprocessable → 422, NotFound → 404, Conflict → 409, Forbidden → 403, Unauthorized → 401, BadRequest → 400.

4.3 Adding an endpoint (checklist)

  1. Create Features/<Area>/<Action>/<Action>.cs with the request, handler and an IEndpoint.
  2. Pick the right auth (§6): RequireMerchantApiKey(), RequireGatewayAdmin(), .RequireAuthorization("ApiClient:…"), or [AuthorizeStrict] for app endpoints.
  3. If it moves money: lock the wallet row, write Topups or Withdrawal and a ledger row in one transaction, and use IManagesOwnTransaction if you manage commits yourself.
  4. Audit admin writes (AdminAuditService.AuditAsync).
  5. Add tests in tests/WalletApi.Gateway.Tests.
  6. Add it to this handbook (§12 and §15).

5. Request pipeline

Order of middleware (Extensions/PipelineExtensions.cs):

  1. UseForwardedHeaders: the real client IP comes from X-Forwarded-For.
  2. UseRateLimiter (§6.11).
  3. UseHttpsRedirection.
  4. Development only: Swagger and seed data.
  5. MapHealthChecks("/healthz").
  6. UseRouting, UseCors() (default policy; named policies per endpoint).
  7. UseAuthentication, UseAuthorization.
  8. MapHub<NotificationHub>("/notifHub") (CORS SignalRCors).
  9. UseKycWebhookBuffering: lets the Didit webhook read its raw body for HMAC checking.
  10. MapControllers(), then MapEndpoints() (slices).

Path base. Production serves everything under /v2, but inside the app Request.Path excludes it. This matters for device signatures (§6.2).


6. Security

6.1 Overview: who can call what

Scheme (handler) Proves Used by Policies / attributes
Bearer (JWT, HMAC-SHA256) A logged-in app user or admin App, dashboard [Authorize], [Authorize(Roles=…)], part of JwtAndDevice
Device (DeviceAuthenticationHandler) The request was signed by a bound, active device App DeviceOnly ([AuthorizeDevice]), part of JwtAndDevice ([AuthorizeStrict])
WebJwt (JWT) A web-dashboard user Campaign screens WebJwtOnly ([AuthorizeWebJwt]), part of JwtOnly ([AuthorizeJwt])
ApiClient (JWT) A partner, with a scope Pay@, EFT Corp, helpdesk, PaySolution ApiClient:PayAt (payat), ApiClient:EftCorp (eft_corp), ApiClient:Helpdesk (helpdesk), ApiClient:PaySolution (zcashdeposit)
MerchantApiKey A gateway merchant Merchants' servers RequireMerchantApiKey()
Callback-specific checks A processor really sent it EFT Corp, Didit, CashSend, refunds §6.10

Bearer and WebJwt tokens (app, admin and web) are signed with the same key, issuer and audience (JWT_* secrets). A plain [Authorize] therefore accepts any of them. What separates an admin from an app user is the role check ([Authorize(Roles=…)]), the device signature (Strict), and the ownership checks inside each action (§6.3). Using separate keys per token type is an open item (§16.1).

Roles (Identity/RoleNames): SuperAdmin, Admin, SupportAdmin, MarketingAdmin, Sales. In this handbook, "admin+support" means SuperAdmin, Admin or SupportAdmin.

6.2 Mobile app requests: JWT + device signature ([AuthorizeStrict])

Every money-moving and personal-data app endpoint uses [AuthorizeStrict] (policy JwtAndDevice). The policy needs three things.

(a) A user JWT (Authorization: Bearer …), from Utilities/JwtToken.GenerateAccessToken:

  • signed with HMAC-SHA256;
  • claims NameIdentifier = Name = the user's ID, and Role = "1";
  • valid 30 minutes; issuer and audience from secrets.

(b) A device signature, checked by DeviceAuthenticationHandler. Required headers:

Header Content
X-Device-Id The bound device's ID
X-Timestamp Unix time in milliseconds
X-Request-Id Unique per request (a UUID)
X-Signature Base64 RSA signature (SHA-256, PKCS#1 v1.5) of the canonical string

Canonical string (lines joined with \n):

METHOD
PATH            ← Request.Path WITHOUT the /v2 path base, e.g. /api/withdrawal
TIMESTAMP       ← the X-Timestamp value
REQUEST_ID      ← the X-Request-Id value
BODY_HASH       ← base64(SHA-256(raw body)); an empty body hashes ""

The handler rejects a request when:

  • a header is missing or the timestamp isn't a number;
  • the X-Request-Id was already seen in the last 10 minutes (replay protection, in-memory cache);
  • the timestamp is more than 3 minutes from server time;
  • the device is unknown or not active;
  • the signature doesn't verify against the device's stored public key (SPKI BEGIN PUBLIC KEY, or PKCS#1 BEGIN RSA PUBLIC KEY).

On success it adds the claims device_id and device_user_id.

(c) Same user. MultiSchemeHandler requires the JWT's NameIdentifier to equal the device's device_user_id. If either identity is missing, it fails closed. A stolen token is useless without the phone, and a phone can't act for another user.

Example:

POST /v2/api/withdrawal
Authorization: Bearer eyJhbGciOi...
X-Device-Id: 7b1f...
X-Timestamp: 1762611200000
X-Request-Id: 2c0b8e7a-...
X-Signature: base64(sign("POST\n/api/withdrawal\n1762611200000\n2c0b8e7a-...\n<sha256 body>"))

6.3 Ownership: the caller may only touch their own data

Strict proves who is calling. Each action then checks that every ID in the request belongs to the caller, using Signing/CallerExtensions.cs:

Helper Meaning
User.CallerId() The app user's uid (app JWT) or the Identity user ID (admin JWT)
User.IsUser(uid) The caller is that app user
await User.OwnsWalletAsync(db, walletId) The wallet exists and its user_id is the caller
User.IsStaff() SuperAdmin, Admin or SupportAdmin

A failed check returns 403. Where a request body carries a UserId, the caller's own ID replaces it (CashSend, vouchers), or the request is rejected if they differ (add-business, Send Zakhaa and PayShap OTPs).

Area Rule
Wallets, top-ups, withdrawals, beneficiaries, PayShap eligibility The wallet must be the caller's
Send Zakhaa, PayShap, Flash, Zapper, Scan to Pay finalise, CashSend The paying wallet must be the caller's
Vouchers Create and reverse from the caller's wallet; redeem into the caller's wallet
NFC tags The tag's wallet must be the caller's
Settings, addresses, legacy KYC profiles The record's user must be the caller; users can't change their own KYC status
KYC documents (/api/documents/{userId}) The user themself, or staff
Admin profiles (/api/admin/me/{id}, /{id}/update) Your own Identity ID, unless SuperAdmin
Platform-wide data (/api/transactions, /api/transactions/{rail}/{id}, wallet analytics, KPI page) Staff only
Maintenance (/api/topup/top-up, wallet create/delete/generate/import, /api/withdrawal/{filePath}, /api/withdrawal/BBM, /api/user/upload/{filepath}) SuperAdmin only

For all new endpoints: use [AuthorizeStrict], then call one of these helpers before reading or changing anything.

6.4 App login, device binding, OTPs and PINs

New phone:                                              Returning user (bound phone):
1. POST /api/auth/login {phone_number, password,        1. POST /api/auth/device/challenge   (Device-signed)
   deviceId}                                               {AuthType: "PIN"|"Biometrics", PIN?, DeviceId}
   → checks the PIN, emails an OTP keyed by deviceId       → for PIN: checks it; returns a random 32-byte
     (no token yet)                                          challenge (cached 5 minutes)
2. POST /api/auth/device/bind {UserId, DeviceId,        2. POST /api/auth/device/verify       (Device-signed)
   PublicKey, OTP, device info}                            {DeviceId, SignedChallenge, LocalVersion}
   → verifies the OTP, registers the device                → verifies the RSA signature of the challenge
     (deactivates the user's previous device and pushes     → access token + refresh token + full user data
     "Device unlinked" to it)
   → access token + refresh token + full user data

Registration (/api/auth/register, /register/business):

  1. The PIN is validated first (6–8 characters including a digit), so a bad PIN doesn't use up the OTP.
  2. The OTP is verified (requested with /api/auth/otp, type EmailVerification).
  3. In one transaction: the user, a personal wallet, settings and notification preferences are created. A business also gets a Business row and a business wallet. An optional promo code credits the wallet, with a ledger row.
  4. The device is bound and tokens are returned.

Login messages: an unknown phone number and a wrong PIN both return "Incorrect phone number or PIN…". Login emails the device-verification OTP directly through OtpSmsService.

OTPs (Utilities/OtpSmsService):

Property Value
Format 6 digits from a cryptographic RNG, sent by email
Storage In-memory cache, 5 minutes, keyed by user ID or device ID
Verification Constant-time compare, single use (a correct code is deleted)
Wrong guesses After 5, the code is discarded and a new one must be requested
Test account The store-review account 2132df52-4479-4e8e-b7d3-cec99e662266 always gets 123456, and can bind a device with 256789, only when Otp:AllowTestAccounts is true (it is in UAT and production)

Where OTPs are required:

  • registration and device binding;
  • Send Zakhaa from R500, which must be the logged-in user's OTP;
  • every PayShap payment, again the logged-in user's;
  • password, phone and email changes, and forgot-password.
  • CashSend needs no OTP (by decision).

PIN storage:

  • PBKDF2-HMAC-SHA256, 210,000 iterations, 32-byte random salt, stored as pbkdf2$<base64> in Users.password (salt in Users.salt), via PasswordValidator.HashPin and VerifyPin.
  • Older SHA-256 hashes still verify and are replaced with PBKDF2 at the next successful login.

App tokens:

  • The access token lasts 30 minutes.
  • JwtToken.GenerateRefreshToken creates a 14-day JWT with audience {JWT_ValidAudience}-refresh, so it can't be used as an access token. It's set as the HttpOnly, Secure cookie refresh_token (domain .zakhaapay.com) and returned at registration.
  • The app has no refresh endpoint: it re-authenticates with the device challenge (step 1–2 above, right).

Login attempts (success or failure, method, reason) are recorded in AuthenticationInfo.

6.5 Admin dashboard (/api/admin-auth)

ASP.NET Identity (ApplicationIdentityDbContext) plus AdminAuthSecurityService:

  • Passwords: Identity's PBKDF2 hasher.
  • Lockout: 5 failed attempts lock the account for 15 minutes (HTTP 423). An email alert goes to LockoutAlertTo, and the attempt is logged with a hashed email and the source IP.
  • Two-factor login is mandatory:
    • an admin without it gets 428 TwoFactorSetupRequired and must enrol: 2fa/setup → 2fa/enable, with a 10-minute single-use setup token (purpose=2fa-enable);
    • TOTP keys are stored encrypted (§6.8) and recovery codes hashed;
    • using a recovery code forces 2FA to be reset and re-enrolled.
  • Tokens:
    • access token from TokenProvider (claims sub, email, roles), valid ExpirationInMinutes (default 5);
    • refresh token: 32 random bytes, only its SHA-256 stored (RefreshTokens in the Identity DB), sent as the HttpOnly cookie refreshToken, rotated on every refresh (the old one is revoked and linked to its replacement), valid RefreshTokenExpirationDays (default 7).
  • Admin user management: register and unlock-account need Admin or SuperAdmin; role, status and delete need SuperAdmin.
  • Audit: every admin write is logged to AuditLogs (AdminAuditService), readable at GET /api/audit.

6.6 Partners: OAuth client credentials (ApiClient)

  • Token request: POST /api/client-auth/token, form fields grant_type=client_credentials&client_id=…&client_secret=… (rate-limited).
  • Client secrets are stored as BCrypt hashes in OAuthClients (ApiDbContext). The client must be Active.
  • The JWT:
    • claims sub (client ID), jti, iat, client_name, and scope (taken from the client record; a requested scope is ignored);
    • issuer, audience and key are the JWT secrets with -api-client appended;
    • lifetime is per client (TokenLifetimeSeconds).
  • Issuing a token revokes the client's other active tokens, except for Pay@, which may hold several. Every token is recorded in OAuthTokens by jti.
  • [ValidateApiClientToken] (on Pay@ and the EFT Corp QR lookup) and its endpoint-filter twin (on cash devices) also check the jti is known, not revoked and not expired. Revocation takes effect immediately.
  • Managing clients: /api/admin/api-clients (SuperAdmin only) registers {ClientId, ClientSecret, Name, Scope, TokenLifetimeSeconds}, and can suspend, activate, rotate the secret or revoke all tokens.

6.7 Merchants: API keys (gateway)

MerchantApiKeyAuthenticationHandler:

  • The header is Authorization: Bearer sk_test_… or sk_live_…, 40–128 characters.
  • The key is looked up by its first 12 characters (Prefix) among unrevoked keys. Its SHA-256 is compared in constant time; the raw key is never stored and is shown once at creation.
  • The key's mode must match its prefix. A suspended merchant is rejected, and a sk_live_ key also requires the merchant to be live.
  • Claims: merchant_id, merchant_mode, merchant_key_id. last_used_at is updated at most once a minute.
  • Webhook signing secrets are stored encrypted (MerchantSecretStore). On rotation the old secret stays valid for 24 hours, so webhooks carry two v1= signatures during that window.
  • Outgoing webhooks: X-Zakhaa-Signature: t=<unix>,v1=hex(HMAC-SHA256(secret, t + "." + body)) and X-Zakhaa-Event-Id.
  • Checkout links carry a signed key. The public /verify and /return endpoints answer every failure with the same 404 checkout.unverifiable, so nobody can probe which refs exist.

6.8 Encryption and outbound signing

  • EncryptionService:
    • AES-256-GCM (authenticated, so tampering is detected), stored as gcm: + base64(nonce, tag, ciphertext), key EncryptionKey;
    • values written before Nov 2026 are AES-CBC without a prefix and still decrypt;
    • used for admin TOTP keys, merchant signing secrets and Zakhaa voucher PINs.
  • Voucher PINs are encrypted by an EF Core value converter (VoucherPinCrypto in Data/AppDBContext.ZakhaaVoucher.cs), so code and the app see the plain PIN while the database holds ciphertext.
  • ApiSigningHandler signs calls to Zakhaa's own middlewares (PayShap, CashSend, …). It adds X-Date, X-Client-API-Key (MIGKeyId), X-Nonce and X-Signature: RSA-SHA256 with RSA_Private_Key over X-Date:{date}\r\nX-Client-API-Key:{id}\r\n{body}.

6.9 Real-time (SignalR /notifHub)

Hub method Auth Use
JoinUserGroup / LeaveUserGroup Authenticated; only ever joins the caller's own group (Context.UserIdentifier) Balance updates (WalletUpdated), KYC results (UserUpdated)
SubscribeToPayment(reference) Open, keyed by the payment reference, which is treated as unguessable Web checkout and desktop Scan to Pay pages. Joins first, then replays a cached result if the payment already finished.

CORS policy SignalRCors allows https://pay.zakhaapay.com (and localhost).

6.10 Inbound callbacks: how each is verified

Endpoint Sender Verification
POST /api/stp/callback EFT Corp merchant notifications AES-decrypted with EFT_Corp_Notification_Key (plain JSON is rejected; the literal TEST gets a 200). The reference must be ours: walletId:ref, or a transaction or ref we already know. Then the transaction is re-fetched from EFT Corp (VerifyAsync) and only EFT Corp's answer is used. Merchant ID, currency and reference must match before anything is credited.
POST /api/stp/refund-webhook/{refundId}/{token} EFT Corp refund outcomes (plain JSON, unsigned) A one-time token per refund (only its SHA-256 is stored), compared in constant time. Then the refund is re-fetched from EFT Corp and must point at the original purchase.
POST /api/auth/kyc/verification/webhook Didit HMAC of the raw body with DiditWebhookSecret, checked against X-Signature. Only status.updated with status Approved is processed.
POST /api/cashsend/webhook CashSend (TransMS) The X-Callback-Token header must equal the transaction's own CallbackToken (constant-time). Otherwise 401 and nothing changes.
POST /api/payat/* Pay@ ApiClient:PayAt token + [ValidateApiClientToken]
GET /api/qr/lookup EFT Corp ApiClient:EftCorp token + [ValidateApiClientToken]
/api/cash-devices/* PaySolution ApiClient:PaySolution token (scope zcashdeposit) + token revocation check; every lookup is scoped to the calling client
POST /api/auth/terminal/session POS terminals A session JWT signed with JWT_SecretKey, with matching serialnumber and a merchantId claim (13.21)

6.11 Rate limits and CORS

Rate limits (all return 429, Retry-After: 60, code rate_limited):

Policy Limit Applied to
auth 20 per minute per client IP and endpoint Login, OTP, register (both), device bind/challenge/verify, admin login/forgot/reset/2FA enable, forgot-password, OTP for profile updates, partner token
stp-initiate 3 per minute per client IP /api/stp/initiate/xpay, /api/stp/resolve/merchant/{id}
MerchantApi per merchant key Gateway merchant API
Checkout public limiter per client Checkout /verify and /return

The client IP comes from X-Forwarded-For, trusted only from a proxy on the same machine (loopback), which is how production runs.

CORS (CorsExtensions):

Policy Origins Used for
Default zakhaapay-topup.web.app, helpdesk.zakhaapay.co.za, zakhaapay.com, dashboard.zakhaapay.com (and www.), localhost:5173; with credentials Everything not named
AllowSpecificOrigins Same, minus helpdesk Portal
SignalRCors pay.zakhaapay.com, localhost:5173; with credentials /notifHub, STP web endpoints
AllowWebView Any origin, no credentials Pop-up messages (read-only)

6.12 Files and KYC documents

Uploads (FileUploadService):

  • at most 5 MB;
  • the MIME type and extension must match an allow-list;
  • each file is virus-scanned by ClamAV (ClamAV_Host/ClamAV_Port), and infected files are rejected;
  • stored in S3 (BucketName).

Users may only reach their own documents; staff can reach anyone's to review KYC. Campaign media is also stored in S3.


7. Money: wallets, ledger, fees, limits, holds, fraud

7.1 Tables

Table Meaning
Wallet Balance holder. balance is a cache. It also holds dailySpent, monthlySpent, trust_tier, trust_tier_override, trust_tier_frozen, last_transaction.
WalletLedgers Append-only: OpeningBalance, ChangeAmount (always positive), Amount, Fee, Vat, Direction (Credit or Debit), ClosingBalance, ReferenceType (e.g. send_zakhaa, payshap_payment, vas_refund, payment_refund), ReferenceId, SourceTable, SourceId. The latest row's ClosingBalance is the balance.
Topups Credits: deposits, card payments, Send Zakhaa received, refunds received, merchant payments, promos. Key fields: wallet_id, sender_id, amount, fee, vat, method, reference, status, TxRef.
Withdrawal Debits: Send Zakhaa sent, refunds paid out, and so on. Key fields: wallet_id, receiver_id, amount, fee, vat, method, type, reference, status.
Fees Fee codes: code, amount, pct (percentage or fixed), status.
TransactionLimit Platform limits by type: Daily, Monthly, Withdrawal, Unverified (amount, status).
AML_Flags Fraud and AML flags for compliance review.
Rail tables VasTransactions, PayshapTransactions, CashSendTransactions, ZapperTransactions, ScanToPayTransaction, CashDeviceTransactions, PayAtPendingTopups, ...

7.2 Rules for writing money code

  1. Lock the wallet row (SELECT … FOR UPDATE). Send Zakhaa locks both wallets in ID order to avoid deadlocks, inside a serializable transaction.
  2. Read the opening balance from the last ledger row, never from Wallet.balance.
  3. Write the Topups or Withdrawal row and the ledger row in the same transaction. Set Wallet.balance = ClosingBalance.
  4. ChangeAmount is positive; Direction gives the sign.
  5. Do notifications only after the commit.

7.3 Fees

  • FeeRepository.ApplyFee(wallet, withdrawal, code): fee = amount × pct% or the fixed amount, and VAT = 15% of the fee. The payer pays amount + fee + VAT.
  • Codes in use:
    • ZAKPEER: Send Zakhaa of type Payment;
    • ZAKVOUCHER: other Send Zakhaa types;
    • CASHDEVICE_DEPOSIT, CASHDEVICE_WITHDRAWAL: cash devices;
    • VAS, PayShap and other rails use their own codes;
    • gateway merchants use fee plans made of fee codes (docs/admin-dashboard-gateway.md).
  • The payer of a gateway order pays no fee; the merchant pays the gateway fee.

7.4 Limits and trust tiers

Every debit goes through these checks in order (the same order in DbWalletLedger and Send Zakhaa):

  1. Frozen (trust_tier_frozen) → blocked: "temporarily restricted".
  2. Unverified ceiling: an unverified user can't exceed the Unverified limit per day.
  3. Trust-tier daily cap (Utilities/TrustTierPolicy):
Tier Daily cap How to reach it (verified users only)
New R500 Default; also every unverified user and any account younger than 7 days
Growing R1,500 Account ≥ 15 days and ≥ 15 outgoing payments
Established R5,000 ≥ 45 days and ≥ 45 outgoing payments
Trusted no cap ≥ 90 days and ≥ 100 outgoing payments

Outgoing payments are ledger debits, excluding refund types. A manual trust_tier_override on the wallet is honoured. TrustTierRecomputeJob recomputes tiers on a fixed schedule (first slot 02:00 SAST), skipping frozen wallets and overrides.

  1. Platform daily limit (TransactionLimit Daily) and monthly limit (Monthly). Send Zakhaa also checks the Withdrawal limit for non-payment types.
  2. Spendable balance (after holds, §7.5). If the amount fits but the fees don't, the error states the maximum you can send.

LimitRefresherBackgroundService resets dailySpent daily, and monthlySpent on the 1st of the month (SAST).

7.5 Deposit holds

PayshapPolicy.ComputeSpendableAsync (Utilities/PayshapEligibility.cs):

  • Recent card and EFT deposits (references matching ^ZP-[A-Z0-9]+-[0-9A-F]{8}(:\d+)?$) are held:
Amount Hold (verified user) Hold (unverified)
< R1,000 1 hour 2 hours
< R5,000 3 hours 6 hours
≥ R5,000 6 hours 12 hours
  • Other recent credits (not Send Zakhaa) are also checked within a 30-minute window.
  • Spendable = balance − held. The balance endpoint and login data show spendable amounts.
  • The refund service lets a merchant also use the held credit of the payment it's refunding.
  • PayShap has extra limits: 2 per day, at least 30 minutes apart (PayshapPolicy.ComputeAsync → can-pay).

7.6 Fraud model

DbWalletLedger.ReserveAsync (used by VAS, PayShap, Zapper, Scan to Pay app payments and CashSend) calls the fraud model before reserving funds:

  • FraudFeatureBuilder builds the features, and FraudPredictionService posts them to /fraud/predict.
  • A score of 0.70 or more writes an AML_Flags row (risk high from 0.75, otherwise medium) and blocks the payment: "Unable to process this transaction at the moment".
  • If the fraud service is down, the call throws and the payment fails (fails closed).
  • Send Zakhaa (WithdrawalRepository) does not call the fraud model.

7.7 Reserve → finalise → refund (DbWalletLedger)

For rails that call an outside provider:

  1. Reserve: run the checks above, debit the wallet with a ledger row, and create a pending rail record.
  2. Call the provider.
  3. Finalise on success: mark the record completed, and the debit stays.
  4. Refund on failure: credit the money back with a *_refund ledger row.

Polling jobs (§10) finish payments whose outcome arrives later.


8. Databases

PostgreSQL through Npgsql, with three DbContexts (DatabaseExtensions.cs, connection from the DB_* secrets):

Context Contents Migrations
AppDBContext (pooled; partial classes per domain) All business data (~80 tables) Migrations/
ApplicationIdentityDbContext ASP.NET Identity: admin users, roles, admin RefreshTokens Migrations/ApplicationIdentityDb/
ApiDbContext OAuthClients, OAuthTokens Migrations/ApiDb/

AppDBContext partial files:

File Tables
AppDBContext.cs Users, Wallet, WalletIDs, ZakhaaIDs, Topups, Withdrawal, Beneficiaries, BankAccounts, Cards, Address, Kins, Documents, Disputes, Notifications, NotificationPreferences, DeviceInfo, Credentials, RefreshTokens, AuthenticationInfo (login attempts), UserSettings, AppSettings, AuditLogs, ErrorLogs, UserActivityLogs, UserNotes, Admins, Roles, Fees, Promo, TransactionLimit, Vendors, VASCategories, vASSubCategories, VasTransactions, StatementTransactions, WebsitePopUpMessage, IdempotencyKeys, AML_Flags, KYC_Profiles
.WalletLedger WalletLedgers
.UserKyc UserKycs
.Business Businesses
.Payshap PayshapTransactions, PayshapBeneficiary, CheckedPayshap
.ScanToPay ScanToPay, ScanToPayTransaction, StpCallbacks, CheckedScanToPay
.Zapper ZapperTransactions
.CashSend CashSendTransactions
.Flash FlashProducts, FlashFloatTopups, FlashFloatCheckpoints
.Pay@ PayAtPendingTopups
.ZakhaaVoucher ZakhaaVoucher
.NfcTag NfcTags
.CashDevice CashDeviceTransactions, CashDeviceWithdrawalRequests
.MarketingCampaign MarketingCampaigns, CampaignSchedules, CampaignRuns, CampaignRunRecipients, CampaignDeliveries, CampaignMedia, CampaignSelectedUsers, CampaignDisplayAd, CampaignDisplayAdEvents, ExternalContacts, ExternalContactImportBatches, ExternalContactImportIssues, ExternalContactSuppressions
.Gateway GatewayMerchants, MerchantApiKeys, MerchantDomains, MerchantSigningSecrets, CheckoutSessions, CheckoutReviewNotes, WebhookEvents, WebhookDeliveries, PaymentRefunds

The gateway tables use snake_case names. checkout_sessions uses Postgres xmin as a concurrency token; lock it with SELECT *, xmin FROM checkout_sessions … FOR UPDATE.


9. External integrations and inbound callbacks

Integration Client (file) Base URL Used for
EFT Corp, direct EftCorpClient EftCorp:BaseUrl (rags in production, pluto in QA); Basic auth; 4 s timeout Verify notifications (purchase/notification/{id}), reversals (DELETE purchase/{id}), refunds (POST purchase/doRefund/{id})
EFT Corp middleware ScanToPayMiddlewareService https://eftcorp.zakhaapay.com/ Start STP/XPay payments, QR payments, status, tenant balance
PayShap middleware PayshapService https://payshap.zakhaapay.com/ PayShap payments, beneficiaries, callbacks (signed, §6.8)
CashSend CashSendService https://payshap.zakhaapay.com/ Cash-send vouchers; callback CashSend:CallbackUrl
Zapper ZapperService https://zapper.zakhaapay.com/ Zapper QR payments
Flash FlashClientService (flash-api, flash-oauth) https://api.flashswitch.flash-group.com VAS
Didit DiditClientService https://verification.didit.me/ KYC sessions and webhook
Fraud model FraudPredictionService https://api.wallet.zakhaapay.com/ (/fraud/predict) Fraud scoring (§7.6)
Google Geocoding / ipwho.is LocalisationService maps.googleapis.com (key GeocodingApiKey), https://ipwho.is/ Area and postal code from GPS at registration, or from the IP address at login
Expo NotificationService Push notifications; ExpoReceiptBackgroundService checks receipts
SMTP EmailSender OTPs, alerts, receipts
AWS S3 FileUploadService KYC documents
ClamAV nClam ClamAV_Host:Port Virus scanning
Nedbank B2B (commented out) Planned

10. Background jobs

Job Cadence What it does
QueuedHostedService + BackgroundTaskQueue continuous Runs work queued by requests, e.g. EFT Corp callback processing, so EFT Corp gets a 200 at once.
ScanToPayStatusBackgroundService 20 s Polls pending Scan to Pay payments; finalises or refunds them.
StpTopUpReconciliationSweep 2 min Re-processes StpCallbacks rows still pending.
PayshapStatusBackgroundService 1 min Polls pending PayShap payments; finalises or refunds them.
LimitRefresherBackgroundService daily (SAST) Resets dailySpent; on the 1st also monthlySpent.
TrustTierRecomputeJob fixed slots from 02:00 SAST Recomputes trust tiers.
CampaignSchedulerBackgroundService 30 s Turns due campaigns into runs.
CampaignExecutionBackgroundService 60 s Executes runs through Services/Processors/*.
ExpoReceiptBackgroundService 1 min Checks Expo push receipts.
CheckoutExpiryJob 1 min Gateway: expires unpaid sessions (15-minute grace for PENDING; flagged sessions skipped).
WebhookDeliveryWorker 5 s Gateway: delivers merchant webhooks (re-signed per attempt, 10 s timeout, no redirects, SSRF-checked; retries at 10 min, 30 min, 1 h, 2 h, 4 h, 8 h, 24 h).

The test host disables hosted background services through ApiFactory.


11. Response conventions

  • Older controllers: ServerResponse { success, message, data }, often with HTTP 200 even on failure. Some newer controller actions use Result<T>. Amounts are decimal rand.
  • Slices (gateway, refunds, cash devices): { "isSuccess": bool, "data": …, "error": { "code", "description" } } with correct HTTP status codes and stable snake_case error codes. Gateway and refund amounts are integer cents.
  • Flash VAS request amounts are cents too ("amountInCents": 2900 = R29.00).
  • New admin list endpoints are paged: page, pageSize → items, totalRecords, totalPages, currentPage, pageSize, hasNextPage, hasPreviousPage.

12. Endpoint reference

Paths are shown without the path base: prefix them with /v2 in production. Strict = [AuthorizeStrict] (user JWT + device signature from the same user, §6.2). admin+support = SuperAdmin, Admin or SupportAdmin. Example request and response bodies for every endpoint are in §20.

12.1 Auth and devices: api/auth

Request/response examples: §20.1

Method Path Auth What it does
POST register none (OTP in body) Verifies the OTP, resolves the area from the address (Google), and registers a personal user (AuthRepository.RegisterAsync), creating the user's wallet.
POST register/business none (OTP in body) Same for a business account (RegisterBusinessAsync).
POST login none Checks the phone number exists, then AuthRepository.LoginAsync → JWT + refresh token. Body: LoginDTO { phone_number, password, deviceId }.
POST otp none Sends an OTP to the user's email or phone (looks the user up by email or phone first).
POST device/bind none (OTP in body) Verifies the OTP and binds the device (DeviceRegisterDTO, including its public key). Clears the old push token. Required before any Strict endpoint.
POST device/challenge Device Re-checks the user's credentials and caches a challenge for the device.
POST device/verify Device Verifies the signed challenge (CryptoUtils.VerifySignature) and updates the device's app version and last seen.
POST device/unlink Device Deletes the device binding.
GET device/status/{DeviceId} none Whether this device is bound.
GET kyc/verification/start/{uid} Strict Creates a Didit session and returns its link.
POST kyc/verification/webhook Didit X-Signature Didit result → DiditWebhookRepository.HandleAsync updates the user's KYC.

Example:

curl -X POST {host}/v2/api/auth/login -H 'Content-Type: application/json' \
  -d '{ "phone_number": "0821234567", "password": "••••••", "deviceId": "7b1f..." }'

Terminals: POST api/auth/terminal/session (anonymous, SessionDto) looks the user up by ID number and registers a business for them if none exists.

12.2 Admin auth and admins: api/admin-auth, api/admin

Request/response examples: §20.2

Method Path Auth What it does
POST admin-auth/login none Admin login (lockout and 2FA apply) → JWT + refresh token.
POST admin-auth/refresh JWT Refresh the session.
POST admin-auth/logout none End the session.
POST admin-auth/register Admin, SuperAdmin Create an admin; emails them; audited.
POST admin-auth/unlock-account Admin, SuperAdmin Unlock a locked-out admin; audited.
POST admin-auth/change-password JWT Change own password; audited.
POST admin-auth/forgot-password, reset-password none Email a reset link; reset with its token; audited.
POST admin-auth/2fa/setup, 2fa/enable none (session-gated) TOTP set-up; enabling validates the code and generates hashed recovery codes.
GET admin/{id}, admin/all?includeInactive= Admin, SuperAdmin Read admins, with roles.
GET admin/me/{id} JWT The current admin.
PUT admin/{id}/update JWT Update an admin profile.
PUT admin/{id}/role, {id}/status SuperAdmin Change role (Identity roles) or status.
DELETE admin/{id}/delete SuperAdmin Delete an admin.

12.3 Users, KYC, documents, notes

Request/response examples: §20.3

Method Path Auth What it does
GET api/user/all?search=&page=&pageSize= admin+support User list (with transaction data).
GET api/user/unverified/Users, {userId} admin+support Unverified users; one user.
GET api/user/{userId}/analytics, {userId}/accounts/{walletId}/transactions, nfc/tags/{walletId} admin+support A user's wallet analytics, transactions and NFC tags.
PUT api/user/{userId} admin+support Edit a user.
PUT api/user/{userId}/suspend, {userId}/reactivate admin+support Suspend or reactivate; audited.
GET api/user/users?date= admin+support HTML KPI dashboard: total and new users (7 days), daily and monthly active users, daily and monthly volumes per rail (excludes internal wallet 9471452626).
POST api/user/add/business Strict Add a business (and business wallet) to the logged-in user's account (UserId must be the caller).
POST api/user/confirm Strict Confirm the password (before account deletion).
POST api/user/updateotp Strict Send an OTP for a profile change.
PUT api/user/update/password/{userId}, update/phone/{userId}, update/email/{userId} Strict (OTP in body) Change credentials.
PUT api/user/updatesignin/{userId} none (OTP in body) Forgot password.
DELETE api/user/delete/{uid} Strict Delete own account.
POST api/user/upload/{filepath} SuperAdmin Maintenance: bulk-create verified users from ./test.csv and email their passwords.
GET, POST, PATCH, DELETE api/kyc/getbyid/{id}, getbyuid{uid}, kyc, {id} Strict Legacy KYC profiles (ID number, status, ID document and selfie stored as bytes). Own profile only; users can't change kyc_status (new profiles start pending). Limits use Users.is_verified, not this table (13.18).
GET, POST, DELETE api/documents/{userId}[/{documentId}] JWT List, upload or delete KYC documents. Uploads are scanned with ClamAV.
POST api/documents/{userId}/approve, unverify, reject admin+support Review KYC documents; audited; rejecting emails the user.
POST, PUT, GET api/notes/user/{userId}, api/notes/{noteId} admin+support Internal notes on a user; audited.
GET, POST, PUT, DELETE api/address/getbyid/{id}, getall/{uid}, api/address, {id} Strict The caller's own addresses only.

12.4 Wallets, balances, transactions

Request/response examples: §20.4

Method Path Auth What it does
GET api/wallet/{uid} Strict The caller's personal wallet (uid must be the caller).
GET api/wallet/{walletId} Strict One of the caller's wallets (balance, tier, limits).
POST, DELETE api/wallet, api/wallet/{id} SuperAdmin Create a wallet (allocates the next wallet ID, 13.22) or delete one. PUT /api/wallet/{id} (balance overwrite) was removed.
GET api/wallet/analytics admin+support Platform-wide totals from the ledger (total spent, received, average monthly spend).
POST api/wallet/generate/{count}, import SuperAdmin Generate wallet IDs into a file, or import that file into WalletIDs (13.22).
GET api/wallet/{walletId}/pay-at-barcode Strict Code 128 PNG barcode for cash deposits at Pay@ retailers.
GET api/topup/{id}, getbywallet/{walletId} Strict The caller's own top-up (money in) records.
POST api/topup/top-up SuperAdmin Manual credit to a wallet (CreateTopUpDto), forced to Completed, amount > 0, ledger row, notification, audited (MANUAL_TOPUP_CREATE). Prefer /api/reconciliation/apply-topup for bank deposits.
PUT api/topup/{id} SuperAdmin Correct a top-up's status (audited). The amount can't be changed.
GET api/transactions?page=&pageSize=&search= JWT All transactions (paged).
GET api/transactions/user/{userId}?take= Strict A user's transactions.
GET api/transactions/{rail}/{id} JWT One transaction on a given rail.
GET api/dashboard/overview?date=, notifications; PUT notifications/{id}/read, notifications/read-all admin+support Dashboard home and admin notifications.
GET api/helpdesk/customers/{ref}, customers/{ref}/logs, wallets/{walletId}, customers/{walletId}/transactions, transactions/{ref}?source_table=&wallet_id=, customers/{walletId}/summary ApiClient:Helpdesk Look-ups for the helpdesk tool.
GET api/audit, api/audit/{id} Admin, SuperAdmin Admin audit log.

12.5 Send Zakhaa (wallet to wallet) and withdrawals: api/withdrawal

Request/response examples: §20.5

All endpoints are Strict.

Method Path What it does
POST api/withdrawal Send money (CreateWithdrawalDto). Verifies the OTP, then WithdrawalRepository.CreateAsync: debits the sender (Withdrawal + ledger) and credits the receiver (Topups + ledger) in one transaction, with fees, limits and the spendable-balance check. Broadcasts the result over SignalR. If reference matches an open checkout session for the receiver, it's processed as a gateway payment (see 13.2).
GET api/withdrawal/getbyid/{id}, getbywalletid{walletid} Withdrawal records.
GET api/withdrawal/getBeneficiaries/{wallet_id} Saved beneficiaries.
POST api/withdrawal/BBM SuperAdmin: pushes a "payment received" amount to the BigBrotherShop real-time channel (legacy; nothing calls it).
POST api/withdrawal/{filePath} SuperAdmin: imports 10-digit Zakhaa IDs from a file in the app folder (plain file name only).

Example:

POST /v2/api/withdrawal
{
  "wallet_id": "ZP1234567890", "receiver_id": "ZP0987654321",
  "amount": 150.00, "method": "Send Zakhaa", "reference": "Rent share",
  "otp": "123456", "type": "...", "uid": "<user guid>"
}

Both users get a push notification.

12.6 PayShap: api/payment

Request/response examples: §20.6

All endpoints are Strict.

Method Path What it does
GET can-pay/{wallet_id} Eligibility: canMakePayment, available, dailyUsed/dailyLimit, nextAllowedAt, reason.
POST initiate-payment Verifies the OTP, then PayshapRepository.ExecuteAsync: reserves funds through the ledger and calls the PayShap middleware. Status is polled by PayshapStatusBackgroundService.
GET getBeneficiaries/{wallet_id} PayShap beneficiaries.
POST callback/register, callback/unregister Register or remove our callback with the middleware.

12.7 Scan to Pay, QR and NFC: api/stp, api/qr/lookup

Request/response examples: §20.7

Method Path Auth What it does
POST stp/initiatestp Strict A Zakhaa user pays with a card through Scan to Pay.
POST stp/finalizestp Strict Finalise and poll that payment.
POST stp/initiate/xpay none (rate limit stp-initiate, CORS) Web checkout (pay.zakhaapay.com): start an Apple, Google or Samsung Pay / bank-app payment. For gateway refs the server-held amount and the canonical reference {walletId}:{ref} are used, never the client's.
GET stp/resolve/merchant/{zakhaaId} none (rate limit stp-initiate, CORS) Merchant display info for a Zakhaa ID.
POST stp/callback EFT Corp (encrypted) Merchant notification: decrypt, store StpCallbacks, reply 200, process in the background (credit, reverse, refund confirmation). See 13.3.
POST stp/refund-webhook/{refundId}/{token} per-refund token EFT Corp refund outcome (see 13.5).
GET stp/status/{paymentId} none Middleware status pass-through.
POST stp/payment/callback none Acknowledges only (legacy).
POST stp/nfc/tags/generate, {tagId}/activate, deactivate, reactivate, revoke, abandon Strict NFC tag life cycle for tap-to-pay, on the caller's own wallet only.
GET stp/nfc/tags/{walletId} Strict The caller's own wallet's tags.
GET stp/nfc/tags/resolve/{tagId} none Resolve a tag to its wallet.
GET qr/lookup/{walletId}/qr JWT The user's QR link.
POST qr/lookup Strict App-initiated QR payment.
GET qr/lookup ApiClient:EftCorp (+ token revocation check) EFT Corp looks up a scanned QR to get the merchant and amount. For checkout refs it returns the server-held amount. A wrong wallet → 400 InvalidCode. Test-mode, suspended, paid or closed sessions → 409 WalletInactive with a specific description.

12.8 Zapper, CashSend, vouchers, Pay@

Request/response examples: §20.8

Method Path Auth What it does
POST api/zapper/initiate Strict Body is the Zapper QR as a base64 string → ZapperService.InitiateAsync.
POST api/zapper/finalize Strict ZapperPaymentNotifyDTO → ZapperRepository.ExecuteAsync (debit and record).
GET api/cashsend/all/{walletId} Strict The user's cash sends.
POST api/cashsend/initiate, cancel, resend-withdrawal, reset-pin Strict Send cash to a phone number (voucher plus PIN), cancel it, resend it, reset the PIN (CashSendRepository, 13.13). The wallet must belong to the caller; no OTP.
POST api/cashsend/webhook callback token CashSend status updates → HandleWebhookAsync. Requires the transaction's X-Callback-Token, else 401.
GET api/vouchers/{voucher}, all/{walletId} Strict Look up a voucher by number (includes its PIN), or list the caller's own wallet's vouchers.
POST api/vouchers/create, redeem, reverse Strict Zakhaa vouchers (13.12). Create and reverse use the caller's own wallet; redeem pays into the caller's own wallet. The PIN isn't required to redeem.
POST api/payat/echoTest ApiClient:PayAt Connectivity test for Pay@.
POST api/payat/account/enquiry ApiClient:PayAt Pay@ validates the account (wallet) number. Returns the owner's details for the till receipt and Amount = 0 (no fixed amount for a top-up), or INVALID_ACCOUNT_NUMBER.
POST api/payat/transaction/authorization → completion / void ApiClient:PayAt Two-phase cash deposit at a retailer: authorise (PayAtPendingTopups), complete (credit the wallet) or void.

Every Pay@ and EFT Corp lookup endpoint also runs [ValidateApiClientToken], which rejects API-client tokens that are unknown, revoked or expired.

12.9 VAS via Flash: api/flash

Request/response examples: §20.9

All endpoints are Strict. Flash amounts are integer cents ("amountInCents": 2900 = R29.00; the [JsonPropertyName("amount")] attribute on that property is ignored by Newtonsoft, §20.0). Purchases go through Execute(req.ToOp()): reserve funds through the ledger, call Flash, then finalise or refund.

Group Endpoints
Catalogue GET products?accountNumber=&group=, product?accountNumber=&productCode=, zakhaapay/product (Zakhaa's active products), zakhaapay/getproducts/{category} (vendors by category)
Airtime and data POST cellular/pinless/purchase
1Voucher POST 1voucher/purchase, disburse, redeem, refund, reverse
Cash-out PIN POST cash-out-pin/purchase, cancel, lookup, reverse
Eezi voucher POST eezi-voucher/purchase, reverse
Flash token / Flash Pay POST flash-token/purchase, reverse; flash-pay/purchase, disburse
Utilities POST prepaid-utilities/purchase (electricity)
Gift vouchers POST gift-vouchers/purchase, reverse

Example (airtime):

POST /v2/api/flash/cellular/pinless/purchase
{ "walletId": "9471452626", "merchantAccountNumber": "0821234567", "productCode": "<Flash product code>",
  "mobileNumber": "0821234567", "amountInCents": 2900, "reference": "...", "paymentMethod": "..." }

12.10 Notifications, settings, content

Request/response examples: §20.10

Method Path Auth What it does
GET api/notifications Strict The user's notifications.
PUT api/notifications/read/{id}, read-all Strict Mark as read.
PUT api/notifications/push-token Strict Register the Expo push token.
PUT api/notifications/{deviceId}/notifications, preferences Strict Per-device and per-category preferences.
GET, POST, PUT api/settings/{uid}, getbyid{id}, api/settings, api/settings/{id} Strict User settings.
GET api/settings/version none App version info (force update).
POST api/settings/version Admin, SuperAdmin Set one of LatestAppVersion, ForceUpdate, UpdateUrlAndroid, UpdateUrlIOS (update URLs must be https). Other keys are rejected.
GET api/mobile/display-ads/home, events; POST events Strict In-app ads and their impression and click events.
POST api/WebsitePopUpMessages Admin, SuperAdmin Add a pop-up message.
GET api/WebsitePopUpMessages, {id}, active, active/{target}, active/android none (CORS AllowWebView) Read pop-up messages.
GET api/links/Tsandcs, api/downloads/latest none Terms link; latest APK.
GET api/support/tawk-login Strict Tawk.to chat identity hash.
POST api/SendEmail/Custom none Sends a custom email. Not protected at all: anyone can call it. Lock it down or remove it.
SignalR /notifHub (hub) Real-time payment results (keyed by reference) and notifications.

12.11 Admin operations

Request/response examples: §20.2 · §20.11

Method Path Auth What it does
GET api/reconciliation/unmatched-credits?search=&page=&pageSize= admin+support Bank deposits that couldn't be matched to a wallet.
GET api/reconciliation/unmatched-credits/{id}/possible-users/ admin+support Candidate users for one unmatched deposit.
POST api/reconciliation/apply-topup admin+support Credit an unmatched deposit to a wallet (ManualTopupDto).
POST api/reconciliation/requestpop admin+support Email the customer asking for proof of payment.
POST, GET, PATCH api/admin/api-clients, {clientId}/suspend, activate, rotate-secret, revoke-tokens SuperAdmin only Partner OAuth clients.
POST api/client-auth/token none (form: client_id, client_secret, grant_type) Partner token (client credentials).
GET, POST, PUT, DELETE api/admin/campaigns, {campaignId}, {campaignId}/runs, runs/{runId}, run/{runId}/retry, media, {campaignId}/media, media/{mediaId}, {campaignId}/reach-estimate, {campaignId}/queue, {campaignId}/cancel, {campaignId}/retry, {campaignId}/schedule/update WebJwt Marketing campaigns (MarketingCampaignService).
GET api/admin/campaigns/external/{batchId}/contacts/, contacts/issues?reason=, summary WebJwt External contact import batches and their issues.
POST api/admin/campaigns/external/subscribe, unsubscribe none Opt an external contact in or out (public links).

12.12 Payment gateway: merchant API (api/checkout-sessions, API key)

Request/response examples: §20.12

The public contract is in docs/merchant-integration-guide.md. Summary:

Method Path What it does
POST /api/checkout-sessions Create a session (orderId is the idempotency key; Idempotency-Key header optional) → ref (ZP-…) + signed checkoutUrl.
GET /api/checkout-sessions/{ref} Session object.
GET /api/checkout-sessions?orderId= Sessions for an order.
POST /api/checkout-sessions/{ref}/expire Cancel an unpaid session → payment.cancelled.
POST / GET /api/checkout-sessions/{ref}/refunds Refund (full or partial) or list refunds.
POST /api/webhooks/validate Confirm a webhook payload was really sent by us.

Portal (no auth, portal CORS, public rate limiter):

Method Path What it does
GET /api/checkout-sessions/{ref}/verify The checkout page checks the signed link (key) and gets what to display. Every failure is the same 404 checkout.unverifiable, so a ref's existence never leaks.
GET /api/checkout-sessions/{ref}/return Where to send the buyer after paying (successUrl/cancelUrl + status).

12.13 Payment gateway: admin (/api/gateway/admin, SuperAdmin or Admin)

Request/response examples: §20.13

Full contract: docs/admin-dashboard-gateway.md.

Area Endpoints
Merchants POST /merchants, GET /merchants (paged, search), GET /merchants/{id}, PATCH /merchants/{id} (status live/suspended, KYB, fee plan, defaults)
Domains POST /merchants/{id}/domains, DELETE /merchants/{id}/domains/{domain}
Keys and secrets POST /merchants/{id}/api-keys (shown once), DELETE /merchants/{id}/api-keys/{keyId}, POST /merchants/{id}/signing-secrets/rotate
Sessions GET /merchants/{id}/checkout-sessions (paged, filter by ref/orderId/status), GET /checkout-sessions/{reference} (detail with payments, refunds, review history, webhook events)
Webhooks GET /merchants/{id}/webhook-events, POST /webhook-events/{eventId}/resend
Review queue GET /review-queue (state/flag/merchant), GET /review-queue/count, POST /review-queue/{ref}/notes, /resolve, /reopen
Fee plans GET/POST /fee-plans, GET/PATCH /fee-plans/{id}
Sandbox POST /sandbox/sessions/{ref}/callback (simulate a processor result for a test session)

Every write is audited (GATEWAY_* actions in AuditLogs).

12.14 Refunds (/api/admin/refunds, SuperAdmin or Admin)

Request/response examples: §20.14

Method Path What it does
POST /api/admin/refunds Refund any incoming payment: { ref?, topupId?, amountCents?, reason }. Works for gateway and ordinary Send Zakhaa or bank payments.
GET /api/admin/refunds List (ref, walletId, topupId, status, sort=newest/oldest, paging).
GET /api/admin/refunds/{id} One refund (with requestedByName, settledByName).
POST /api/admin/refunds/{id}/check Ask EFT Corp about a PENDING bank refund and apply the answer.
POST /api/admin/refunds/{id}/settle Manually settle a PENDING bank refund: { outcome: succeeded/failed, note }.

12.15 Cash devices

Request/response examples: §20.15 · §20.16

Partner API (/api/cash-devices, ApiClient:PaySolution):

Method Path What it does
GET /wallets/{walletId} Verify a wallet before a deposit or withdrawal at a cash device.
POST /transactions Submit a device transaction (deposit, dispensed withdrawal, collection). Idempotent on device serial + transaction reference.
GET /transactions/{deviceSerial}/{transactionReference} Status of a submitted transaction.
POST /withdrawals/request Customer requests a cash withdrawal → OTP-protected withdrawal request.
POST /withdrawals/reverse Reverse a withdrawal (for example cash not dispensed), within 24 hours.

Admin (/api/admin/cash-devices, amounts in cents): GET /devices, /transactions, /transactions/{zakhaaReference}, /collections, /withdrawal-requests, /reversals.

13. Key flows, end to end

13.1 Gateway checkout with a card or bank app (EFT Corp)

Request/response examples: §20.12 · §20.13

Merchant ─POST /api/checkout-sessions──────────▶ API: session CREATED, checkoutUrl signed (HMAC "key")
Buyer ───opens pay.zakhaapay.com/qr?...&key── ▶ GET /checkout-sessions/{ref}/verify  (link check)
Buyer ───picks Apple Pay / bank app ─────────▶ POST /api/stp/initiate/xpay
                                               API: amount = server-held total, reference = "{walletId}:{ref}"
                                               → EFT Corp middleware → buyer approves in their app
EFT Corp ─POST /api/stp/callback (encrypted)─▶ decrypt → StpCallbacks row → 200 → background:
                                               VerifyAsync(txn) → CompletePaymentAsync:
                                               merchant Topups ("{ref}:{txn}", method eftcorpSTP) + ledger,
                                               gateway fee entries, session SUCCESSFUL,
                                               webhook_events row (outbox, same transaction)
WebhookDeliveryWorker ─payment.succeeded (signed)─▶ merchant notifyUrl
  • Fees: from the merchant's fee plan (fee codes in Fees). The merchant is credited net; fee and VAT ledger entries are written.
  • Reconciliation: EXACT, UNDERPAID or OVERPAID (the tip counts).
  • Review flags: LATE (paid after expiry or cancel), DUPLICATE (a second payment), WALLET_MISMATCH, MODE_MISMATCH. Flagged sessions go to the review queue and are not expired by the expiry job.
  • If crediting fails permanently, the payment is reversed with EFT Corp. Transient failures stay pending for the sweep.

13.2 Gateway payment through Send Zakhaa

Request/response examples: §20.5

When POST /api/withdrawal carries a reference equal to an open checkout ref (or {walletId}:{ref}) for the receiving merchant wallet:

  • Live mode: the payer is debited without the ZAKPEER fee, the merchant is credited ("Merchant payment") with the gateway fee entries, the session becomes SUCCESSFUL (processorPaymentId = "zw_{withdrawalId}"), and payment.succeeded is queued.
  • Test mode: no money moves. The session is marked paid and the app gets a normal success response.
  • The wrong wallet, a closed or already-paid session, or a suspended merchant is rejected before any money moves.

13.3 EFT Corp callback processing (StpCallbackProcessor.ResolveAsync)

Request/response examples: §20.7

  1. Verify with EFT Corp (GET purchase/notification/{txn}), retrying while PENDING.
  2. Approved and ours (merchant ID, ZAR, exact reference) → credit (idempotent).
  3. FAILED, REVERSED, DECLINED, CANCELLED or REFUNDED:
    • first, PaymentRefundService.ConfirmFromEftAsync: is this one of our refunds or reversals? If so, settle it;
    • otherwise claw back any credit (ScanToPayService.ReverseTransactionAsync).
  4. Webhooks without walletId:ref are accepted only for a transaction or ref we already know (ScanToPayController.KnownReferenceAsync).

13.4 Refund through Send Zakhaa (wallet rail)

Request/response examples: §20.12 · §20.14

PaymentRefundService.RefundAsync, rail zakhaa_wallet:

  1. Lock the payment's Topups row (FOR UPDATE); remaining = gross − succeeded refunds; one pending refund at a time.
  2. Check the receiver can pay (spendable balance + this payment's own held credit).
  3. One transaction: receiver Withdrawal "Refund to {payer}" + ledger debit; payer Topups "Refund from {merchant}" + ledger credit.
  4. If it's a session's payment: refundedAmountCents goes up, the session becomes PARTIALLY_REFUNDED or REFUNDED, and payment.refunded is queued (a duplicate refund keeps the order paid and sends refund.duplicate: true).
  5. Push notification to the payer (TransactionType.PaymentRefunded).

13.5 Refund through the bank (EFT Corp rail)

Request/response examples: §20.7 · §20.14

  1. Choose the operation: a reversal (DELETE purchase/{txn}) for the full amount on the same SAST day with nothing refunded before; otherwise a refund (POST purchase/doRefund/{txn} with {amount, webhook}).
  2. Commit first: a PENDING refund row + a pending receiver Withdrawal (funds held). For refunds, a fresh random token is created and only its SHA-256 is stored; the webhook URL is {EftCorp:RefundWebhookBaseUrl}/{refundId}/{token}.
  3. Call EFT Corp. Rejected → FAILED and the funds are released (codes 445 debit card, 444/514 not reversible, 515, 432, 436, 513 are mapped to plain messages). Timed out → stays PENDING.
  4. The outcome arrives by webhook:
    • Refund: POST /api/stp/refund-webhook/{refundId}/{token} (token checked, 200 straight away, background job verifies with EFT Corp: the record must point at the original purchase);
    • Reversal: the merchant notification REVERSED.
  5. Only bank response 00 counts as success. A failed reversal is also reported as REVERSED.
  6. Fallbacks: POST /api/admin/refunds/{id}/check, then /settle.

13.6 PayShap send

Request/response examples: §20.6

(Full details: 13.14.)

initiate-payment → eligibility (PayshapPolicy) → DbWalletLedger reserves → middleware call (signed) → PayshapStatusBackgroundService polls → finalise (debit stays) or refund (reservation released) → notification.

13.7 Pay@ cash deposit

Request/response examples: §20.8

Account number = 13010 + wallet ID. Limits R10–R15,000. Fee DEPOSIT (fallback DEPOSIT/TRANSFER) + 15% VAT is deducted, and the net is credited with reference PayAt Top-up, ID: …. Completions without a prior authorisation (store-and-forward) are still credited from the tenders. Pay@ reversals aren't implemented. Errors return 500 so Pay@ retries.

Retailer scans the wallet's Pay@ barcode → account/enquiry → transaction/authorization (PayAtPendingTopups) → transaction/completion credits the wallet (Topups + ledger), or transaction/void cancels.


13.8 Send Zakhaa (wallet to wallet)

Request/response examples: §20.5

WithdrawalController.Create → WithdrawalRepository.CreateAsync:

  1. Controller checks: a user JWT and device signature (Strict); the sender wallet's user_id must equal the JWT user (else 403); an OTP from R500.
  2. Serializable transaction, both wallets locked FOR UPDATE in ID order.
  3. Rejections: sending to the same wallet, an amount ≤ 0, or a frozen sender.
  4. Gateway check: if the reference is an open checkout session for the receiver, it follows gateway rules instead (13.2).
  5. Limits: the Withdrawal limit, CheckLimits (unverified, daily, monthly), then the trust-tier cap.
  6. Fee: ZAKPEER or ZAKVOUCHER (0 for gateway orders), plus 15% VAT on the fee. Total = amount + fee + VAT.
  7. Balance: the opening balance comes from the last ledger row; the spendable balance is checked (holds), with explicit messages for "held funds" and "fees push you over".
  8. Writes: sender Withdrawal (Completed) and a debit ledger row send_zakhaa; the beneficiary is saved; the receiver gets a Topups row and a credit ledger row (or the merchant credit for a gateway order). One SaveChanges and commit.
  9. After commit: a SignalR TransactionUpdate to the receiver, push and in-app notifications to both, and a payment-result broadcast for the web or desktop page.

13.9 Buying airtime or another VAS product

Request/response examples: §20.9

FlashController → Execute(req.ToOp()) → VasService:

  1. DbWalletLedger.ReserveAsync runs the limit checks (§7.4), the fraud model (§7.6) and the spendable-balance check, debits the wallet and creates a VasTransactions row.
  2. Flash is called.
  3. On success it's finalised (receipt, voucher PIN or token). On failure RefundAsync credits the money back (vas_refund).

VasService.CheckBalanceAndTopUp monitors the Flash float.

13.10 Cash devices (PaySolution)

Request/response examples: §20.15 · §20.16

  • Deposit (POST /api/cash-devices/transactions, type 1): idempotent per device + transaction reference. Fee CASHDEVICE_DEPOSIT; the wallet is credited with a ledger row.
  • Withdrawal:
    1. POST /withdrawals/request checks the wallet and balance and sends a one-time code (4–8 digits, valid 5 minutes, only its salted hash stored, 3 attempts) to the holder's app. A new request cancels earlier pending ones; a retry with the same request reference doesn't send a second code.
    2. The device then submits type 3 with the code. The wallet is debited, with fee CASHDEVICE_WITHDRAWAL.
  • Reversal (POST /withdrawals/reverse): within 24 hours, for cash not dispensed. It refunds amount + fee + VAT and is claimed atomically, so it can't refund twice.
  • Collection (type 2): a cash-in-transit pickup. Records the expected amount (deposits minus dispensed withdrawals since the last collection) and the variance. No money moves.

13.11 How money gets into a wallet (every source)

Request/response examples: §20.4 · §20.11

Source Entry point Fee code Topups method / ledger ReferenceType Notes
Send Zakhaa received POST /api/withdrawal (sender) sender pays ZAKPEER/ZAKVOUCHER Send Zakhaa / topup Receiver credit is written in the sender's transaction (13.8).
Card / Apple / Google / Samsung Pay (XPay, STP top-up) EFT Corp callback (/api/stp/callback) S2P_DEPOSIT (fallback DEPOSIT) + 15% VAT, deducted eftcorpSTP / eft_corp_topup Reference {txRef}:{eftTransactionId}, idempotent. Held 1–6 h (§7.5).
Gateway checkout payment EFT Corp callback or Send Zakhaa merchant fee plan eftcorpSTP or Send Zakhaa / gateway entries See 13.1, 13.2.
Cash at a retailer (Pay@) POST /api/payat/transaction/completion DEPOSIT (fallback DEPOSIT/TRANSFER) + VAT reference PayAt Top-up, ID: … R10–R15,000 per deposit (13.7).
Cash at a cash device POST /api/cash-devices/transactions (type 1) CASHDEVICE_DEPOSIT cash-device entries 13.10.
Bank deposit or EFT (manual) POST /api/reconciliation/apply-topup (admin) entered by admin Bank Deposit or Bank Transfer / manual_topup Matches bank statement lines (13.17).
1Voucher redemption POST /api/flash/1voucher/redeem 1VOUCHERREDEEM + VAT 1Voucher 13.9.
Zakhaa voucher redemption POST /api/vouchers/redeem none Zakhaa Voucher / topup 13.12.
Refund received refunds, VAS, PayShap, Zapper, STP, CashSend failures none *_refund, payment_refund Reversal of an earlier debit.
Promo code at registration POST /api/auth/register none Promo / promo Promo table: code, amount, dates, active.
Manual credit by a SuperAdmin POST /api/topup/top-up none admin-chosen / topup Audited; was open to app users before Nov 2026.

13.12 Zakhaa vouchers (cash-like vouchers between users)

Request/response examples: §20.8

  • Create (POST /api/vouchers/create, {UserId, PIN, WalletId, Amount}):
    • runs in a serializable transaction with the wallet locked;
    • applies the same checks as Send Zakhaa (frozen, Withdrawal limit, daily/monthly/unverified limits, trust-tier cap, spendable balance), and fee ZAKVOUCHER + 15% VAT;
    • creates a 12-digit voucher number (cryptographic RNG, unique) valid 1 year, a Withdrawal row (Zakhaa Voucher) and a debit ledger row zakhaa_voucher_purchase;
    • stores the PIN encrypted (AES-GCM; decrypted transparently when read, so the app still sees it).
  • Redeem ({Voucher, WalletId}): locks the voucher, checks it isn't expired, reversed or redeemed, then credits the target wallet (Topups + ledger topup) and marks it redeemed. The PIN isn't required. The target wallet must belong to the logged-in user; an unknown wallet gets "Zakhaa wallet not found."
  • Reverse ({UserId, Voucher, WalletId}): only while unredeemed and unexpired; refunds the creator's wallet. Only the creator (the logged-in user) can reverse, into their own wallet.
  • Expiry: SetExpiredAsync refunds an expired voucher.
  • Lookup by number returns the voucher including its PIN; listing by wallet is limited to the caller's own wallets.

13.13 CashSend (send cash to a phone number)

Request/response examples: §20.8

Through TransMS / ABSA, via Zakhaa's middleware (payshap.zakhaapay.com):

  1. Initiate (/api/cashsend/initiate):
    • de-duplicated on user, reference, same day and amount;
    • a per-transaction CallbackToken is generated;
    • DbWalletLedger.ReserveAsync (product CASHSEND: limits, fraud, holds) debits the wallet;
    • TransMS Initiate is called with mobile number, amount, references, beneficiary name, note, callback URL and token. If it's rejected (RJCT) the money is refunded; otherwise the record is finalised.
  2. Statuses: Pending → Active (bank accepted, withdrawal number sent to the recipient) → Redeemed (cash collected) | Cancelled (refunded) | Rejected/Failed (refunded).
  3. Cancel, resend withdrawal number and reset PIN call TransMS. Cancel is only allowed in Pending/Active once TransMS has given a CashSendId; the final state arrives by webhook.
  4. Webhook (/api/cashsend/webhook):
    • matched by end-to-end ID (our reference) or UETR;
    • Cancelled/Rejected refund the wallet (cashsend_refund);
    • recovery case: if our side had marked it Failed (and refunded) but TransMS reports it initiated, the wallet is debited again and a new Active row is added.
    • the X-Callback-Token header must match the transaction's CallbackToken, otherwise 401 and nothing changes.
  5. Stored data: mobile number, beneficiary name, note, WithdrawalNumber, UETR, IP address. The collection PIN is no longer stored (older rows may still hold one).
  6. Access: no OTP; the wallet must belong to the logged-in user, whose ID replaces any UserId in the body.

13.14 PayShap (pay a bank account)

Request/response examples: §20.6

  • Eligibility: GET /api/payment/can-pay/{wallet} (2 per day, 30 minutes apart, available amount).
  • Pay (POST /api/payment/initiate-payment):
    1. The OTP is checked and the wallet ownership verified.
    2. Duplicates are rejected (same user, reference, day and amount). A new TransRef GUID is set.
    3. ReserveAsync (PAYSHAP) debits the wallet.
    4. The middleware POST /admin/mig/initiate-payment is called (signed). Unless it fails, the record is finalised, the beneficiary saved (account number, type, branch, holder, bank) and a push sent. On failure it's refunded with the bank's reason.
  • Follow-up: PayshapStatusBackgroundService polls GET /admin/mig/status/{ref} every minute. It moves Pending to Completed or Failed (refunding on failure), and also handles a late Failed → Completed.
  • Stored data: account details in PayshapBeneficiary; PayshapTransactions holds UniqueEftNumber, PaymentNumber, ApiRef, IP address.

13.15 Zapper and Scan to Pay (paying a merchant's QR from the app)

Request/response examples: §20.7 · §20.8

  • Zapper:
    • initiate decodes the scanned base64 QR via the Zapper middleware;
    • finalize de-duplicates (user, reference, day, Pending/Completed), reserves funds (ZAPPER) and notifies Zapper "success" with the amount in cents. On success it's finalised and a push sent; on failure it's refunded and Zapper is told "failed".
  • Scan to Pay (app):
    • initiatestp stores a ScanToPay row and fetches the QR's details (amount, merchant name, reference) from the EFT Corp middleware;
    • finalizestp (ownership checked): one payment per QR, same reference by the same user blocked for 3 minutes, reserve (SCAN2PAY), then the middleware Finalize. A PENDING result is polled twice, 5 seconds apart; anything not SUCCESSFUL is refunded. Success → finalised, push sent, and the EFT Corp float check is queued.
    • The tip (AdditionalFields.tipAmount) is added to the wallet debit inside DbWalletLedger.ReserveAsync and recorded as TipAmount; the same total is sent to EFT Corp.
    • ScanToPayStatusBackgroundService (20 s) settles pending ones and refunds failures.
  • Floats: when the EFT Corp tenant wallet is ≤ R2,000, PayshapService.LoadEFTCorpWallet (/admin/mig/eftcorp) tops it up (10-minute cooldown). Flash has the same mechanism (/admin/mig/flash, 15-minute cooldown).

13.16 NFC tags (tap to pay a person)

Request/response examples: §20.7

  • Generate (Strict; the wallet must be Active): a random opaque TagId and a URL to write onto the tag. Status Pending.
  • Life cycle: activate (Pending→Active), deactivate (Active→Inactive), reactivate, revoke, abandon. All require the tag to belong to the given wallet.
  • Resolve (GET /api/stp/nfc/tags/resolve/{tagId}, public): returns the wallet to pay only if the tag is Active and the wallet is active. Every other case returns the same tag_unavailable, so tag IDs can't be probed.

13.17 Bank reconciliation (unmatched deposits)

Request/response examples: §20.11

  • Statement lines live in StatementTransactions and are loaded outside this API: nothing in this code inserts them.
  • Unmatched credit: a statement line with Amount > 0 with no top-up where amount + fee + vat equals the line amount and the top-up reference contains the line's TransactionId.
  • Possible users: candidates are scored from signals in the statement reference (phone number, wallet ID, names) and returned with their wallets.
  • apply-topup: locks the wallet, rejects a duplicate reference, then writes the top-up and the ledger row manual_topup. It's audited, then the user is notified directly (SignalR balance update + push).
  • requestpop: emails the customer for proof of payment.

13.18 Identity verification (KYC)

Request/response examples: §20.1 · §20.3

Two paths set Users.is_verified, which unlocks limits (§7.4).

1. Automatic (Didit)

  • GET /api/auth/kyc/verification/start/{uid} returns a Didit session link.
  • Didit's webhook is HMAC-checked, then DiditKycService (idempotent per session) compares Didit's result with the profile:
    • ID number against the document or personal number;
    • date of birth;
    • surname similarity ≥ 90% (required);
    • first names exact or ≥ 80% similar.
  • It always stores a UserKycs row: document number and type, face-match and liveness scores, status, and the full raw Didit payload.
  • Outcomes:
Didit result Effect on the user
Any mismatch (ID, DOB or name) is_verified = false, need_review = true, is_active = false (account frozen; login says "frozen and pending review")
Approved, exact name is_verified = true
Approved, similar name not verified, need_review = true
In review / other not verified
  • The user is told through SignalR (UserUpdated).

2. Manual (documents)

  • The user uploads ID documents (S3, 5 MB, allow-listed types, ClamAV scan).
  • An admin approves: every document must be Uploaded, then is_verified = true and need_review = false.
  • An admin rejects: the document becomes Rejected, is_verified = false, and the user is emailed the reason.
  • unverify resets verification. Every action is audited.

Legacy: KYC_Profiles (/api/kyc) stores the ID number, status and ID document and selfie images as bytes in the database. It doesn't drive limits.

13.19 Notifications

Request/response examples: §20.10

Channel How
In-app list Notifications rows (GET /api/notifications, mark read).
Push Expo (https://exp.host/--/api/v2/push/send, batches of 100). Tokens live on Users.ExpoPushToken and per device (DeviceInfo.ExpoPushToken, PushNotificationsEnabled). Receipts are checked for campaigns.
Real-time (SignalR, group = user ID) WalletUpdated (new balance, transaction, message), UserUpdated (KYC result), BigBrotherShop (/withdrawal/BBM), plus payment-result broadcasts per payment reference.
Email SMTP2GO (mail-eu.smtp2go.com:587, TLS) for OTPs, receipts, rejection notices and alerts.

Message texts are built in NotificationRepository.GenerateNotification, one per TransactionType.

13.20 Marketing campaigns and in-app content

Request/response examples: §20.10 · §20.11

  • Who manages it: web-dashboard users (/api/admin/campaigns).
  • Audiences: all users, selected users, filtered users (active, verified, roles, cities, created date range, last login range), or imported external contacts. External contacts come in batches with per-row issues and a suppression list; the public subscribe/unsubscribe endpoints manage the list.
  • Scheduling: once, daily, weekly, monthly or yearly. Content is plain text or HTML, with media in S3.
  • Statuses:
    • campaign: Draft → Queued → Active → Completed / PartiallyFailed / Failed / Cancelled;
    • run: Pending → Processing → WaitingForReceipts → done;
    • delivery: Pending / Sending / Sent / Failed / Retrying / Cancelled / PendingReceipt.
  • Channels: only Push and Email are implemented. SMS, in-app pop-up, carousel and landing-page processors throw NotImplemented.
  • Separate in-app content: display ads (/api/mobile/display-ads, with impression and click events) and website/app pop-up messages (/api/WebsitePopUpMessages).

13.21 POS terminals

Request/response examples: §20.1

POST /api/auth/terminal/session (anonymous) logs a merchant's POS terminal into their business account:

  1. The terminal sends a session JWT minted by the POS service with the same JWT_SecretKey, with claims serialnumber (must equal the body's SN) and merchantId (= the owner's ID number).
  2. Known merchant: the device in the request is bound alongside the owner's phone (deleteDevice: false, the phone stays linked), and app tokens are returned.
  3. Unknown merchant: details are fetched from Terminal:BootstrapUrl (default: the hard-coded test service https://zakhaa-test-service.us-east-1.elasticbeanstalk.com/api/vas/user/details), with the token in the query string, and the business user is created locally.

13.22 Accounts, devices and wallet IDs

Request/response examples: §20.3 · §20.4

  • Wallet IDs are 10-digit numbers:
    • created into the file Zakhaa Pay ZakhaaIDs.txt (POST /api/wallet/generate/{count}, non-cryptographic random);
    • imported into WalletIDs (/import);
    • allocated in order on wallet creation (first unassigned, then marked assigned).
  • Pay@ account number = 13010 + wallet ID (the barcode from /pay-at-barcode).
  • One active device per user: binding a new device deactivates the old one (IsActive=false, IsDelete=true).
  • Account deletion is soft: is_active=false, is_deleted=true, Deleted_At. Data is kept.
  • Suspension (admin) toggles is_active. Login rejects inactive users, with a "frozen, pending review" message when need_review is set.
  • App version gate: AppSettings keys LatestAppVersion, ForceUpdate, UpdateUrlAndroid, UpdateUrlIOS → GET /api/settings/version.
  • KPI page: GET /api/user/users (staff only) renders an HTML dashboard of business metrics: total and new users, daily and monthly active users, and volumes per rail.

14. Testing and local development

14.1 The test project

tests/WalletApi.Gateway.Tests (xUnit) runs the real app in memory (ApiFactory, a WebApplicationFactory) against a real PostgreSQL. About 200 tests in these suites:

Suite Covers
GatewaySnapshotTests The gateway's HTTP output, byte for byte, against Contract/Snapshots/gateway-http.txt
ApiContractTests Merchant API contract (create, idempotency, errors)
GatewayAcceptanceTests (T01–T15) End-to-end gateway behaviour: payments, fees, ledger entries, webhooks, expiry
GatewayAdminPhase2Tests Merchant search and paging, review queue, fee plans
GatewayRefundTests Wallet and bank refunds, partial refunds, EFT Corp webhooks and per-refund tokens, settle and check
SendZakhaaGatewayTests Paying a checkout order with Send Zakhaa (live and test mode)
CashDeviceContractTests Cash-device deposits, withdrawals, OTPs, reversals
MessagingTests The in-house dispatcher and its behaviours
SecurityFixesTests AES-GCM (round trip, tamper detection, old CBC values), PBKDF2 PINs and upgrade, single-use OTPs and attempt limit, test-account switch, voucher PIN encryption at rest, CashSend webhook token

14.2 Running the tests

DB-backed tests ([PgFact]) need PostgreSQL in GATEWAY_TEST_PG and are skipped without it. To use a throw-away local server:

initdb -D /tmp/pg -U postgres --auth=trust
pg_ctl -D /tmp/pg -o "-p 55491 -k /tmp -c listen_addresses=127.0.0.1" start
GATEWAY_TEST_PG="Host=127.0.0.1;Port=55491;Username=postgres" dotnet test tests/WalletApi.Gateway.Tests
pg_ctl -D /tmp/pg stop

How the test app is set up:

  • ApiFactory sets dummy secrets (JWT, EncryptionKey, S3, ClamAV …) and runs as Development.
  • It removes all hosted services, so background jobs don't run. A test that needs queued work runs it by hand (IBackgroundTaskQueue.DequeueAsync).
  • Outside services (IEftCorpClient, notifications) are replaced with fakes in each test class.

One test is skipped on purpose (the public rate limiter, which depends on timing).

14.3 Running the API locally

  1. Create appsettings.Development.json with a Config section holding every SecretKeys value (§3.2), using UAT or local values. It's git-ignored.
  2. Run dotnet run. Swagger is at /swagger and seed data is created at startup.
  3. App endpoints need a bound device (§6.2). It's usually easier to test them through the mobile app pointed at your machine, or to call repositories directly from a test.

15. Endpoint index

Every endpoint, sorted by path (without the /v2 base). Auth: S = Strict (JWT + device), D = Device only, J = any JWT, A = Admin/SuperAdmin, AS = admin+support (SuperAdmin, Admin, SupportAdmin), SA = SuperAdmin only, W = WebJwt, M = merchant API key, P:x = partner token with scope x, GA = gateway/refund/cash-device admin (SuperAdmin, Admin), — = none or own verification. § = section with details.

Method Path Auth §
POST /api/address S 12.3
PUT, DELETE /api/address/{id} S 12.3
GET /api/address/getall/{uid} S 12.3
GET /api/address/getbyid/{id} S 12.3
GET /api/admin/all A 12.2
GET /api/admin/{id} A 12.2
DELETE /api/admin/{id}/delete SA 12.2
PUT /api/admin/{id}/role SA 12.2
PUT /api/admin/{id}/status SA 12.2
PUT /api/admin/{id}/update J 12.2
GET /api/admin/me/{id} J 12.2
POST /api/admin-auth/2fa/enable — (setup token) 12.2
POST /api/admin-auth/2fa/setup — (setup token) 12.2
POST /api/admin-auth/change-password J 12.2
POST /api/admin-auth/forgot-password — 12.2
POST /api/admin-auth/login — 12.2
POST /api/admin-auth/logout — 12.2
POST /api/admin-auth/refresh J + cookie 12.2
POST /api/admin-auth/register A 12.2
POST /api/admin-auth/reset-password — 12.2
POST /api/admin-auth/unlock-account A 12.2
GET, POST /api/admin/api-clients SA 12.11
PATCH /api/admin/api-clients/{clientId}/activate SA 12.11
PATCH /api/admin/api-clients/{clientId}/revoke-tokens SA 12.11
PATCH /api/admin/api-clients/{clientId}/rotate-secret SA 12.11
PATCH /api/admin/api-clients/{clientId}/suspend SA 12.11
GET, POST /api/admin/campaigns W 12.11
GET, PUT /api/admin/campaigns/{campaignId} W 12.11
POST /api/admin/campaigns/{campaignId}/cancel W 12.11
GET /api/admin/campaigns/{campaignId}/media W 12.11
POST /api/admin/campaigns/{campaignId}/queue W 12.11
GET /api/admin/campaigns/{campaignId}/reach-estimate W 12.11
POST /api/admin/campaigns/{campaignId}/retry W 12.11
GET /api/admin/campaigns/{campaignId}/runs W 12.11
PUT /api/admin/campaigns/{campaignId}/schedule/update W 12.11
POST /api/admin/campaigns/external/subscribe — 12.11
POST /api/admin/campaigns/external/unsubscribe — 12.11
GET /api/admin/campaigns/external/{batchId}/contacts/ W 12.11
GET /api/admin/campaigns/external/{batchId}/contacts/issues W 12.11
GET /api/admin/campaigns/external/{batchId}/summary W 12.11
POST /api/admin/campaigns/media W 12.11
DELETE /api/admin/campaigns/media/{mediaId} W 12.11
POST /api/admin/campaigns/run/{runId}/retry W 12.11
GET /api/admin/campaigns/runs/{runId} W 12.11
GET /api/admin/cash-devices/collections GA 12.15
GET /api/admin/cash-devices/devices GA 12.15
GET /api/admin/cash-devices/reversals GA 12.15
GET /api/admin/cash-devices/transactions GA 12.15
GET /api/admin/cash-devices/transactions/{zakhaaReference} GA 12.15
GET /api/admin/cash-devices/withdrawal-requests GA 12.15
GET, POST /api/admin/refunds GA 12.14
GET /api/admin/refunds/{id} GA 12.14
POST /api/admin/refunds/{id}/check GA 12.14
POST /api/admin/refunds/{id}/settle GA 12.14
GET /api/audit A 12.4
GET /api/audit/{id} A 12.4
POST /api/auth/device/bind — (OTP) 12.1
POST /api/auth/device/challenge D 12.1
GET /api/auth/device/status/{DeviceId} — 12.1
POST /api/auth/device/unlink D 12.1
POST /api/auth/device/verify D 12.1
GET /api/auth/kyc/verification/start/{uid} S 12.1
POST /api/auth/kyc/verification/webhook — (Didit HMAC) 12.1
POST /api/auth/login — 12.1
POST /api/auth/otp — 12.1
POST /api/auth/register — (OTP) 12.1
POST /api/auth/register/business — (OTP) 12.1
POST /api/auth/terminal/session — 12.1
POST /api/cash-devices/transactions P:zcashdeposit 12.15
GET /api/cash-devices/transactions/{deviceSerial}/{transactionReference} P:zcashdeposit 12.15
GET /api/cash-devices/wallets/{walletId} P:zcashdeposit 12.15
POST /api/cash-devices/withdrawals/request P:zcashdeposit 12.15
POST /api/cash-devices/withdrawals/reverse P:zcashdeposit 12.15
GET /api/cashsend/all/{walletId} S 12.8
POST /api/cashsend/cancel S 12.8
POST /api/cashsend/initiate S 12.8
POST /api/cashsend/resend-withdrawal S 12.8
POST /api/cashsend/reset-pin S 12.8
POST /api/cashsend/webhook — (callback token) 12.8
GET, POST /api/checkout-sessions M 12.12
GET /api/checkout-sessions/{ref} M 12.12
POST /api/checkout-sessions/{ref}/expire M 12.12
GET, POST /api/checkout-sessions/{ref}/refunds M 12.12
GET /api/checkout-sessions/{ref}/return — (portal) 12.12
GET /api/checkout-sessions/{ref}/verify — (portal, signed link) 12.12
POST /api/client-auth/token — (client secret) 12.11
GET /api/dashboard/notifications AS 12.4
PUT /api/dashboard/notifications/{id}/read AS 12.4
PUT /api/dashboard/notifications/read-all AS 12.4
GET /api/dashboard/overview AS 12.4
GET, POST /api/documents/{userId} J 12.3
DELETE /api/documents/{userId}/{documentId} J 12.3
POST /api/documents/{userId}/approve AS 12.3
POST /api/documents/{userId}/reject AS 12.3
POST /api/documents/{userId}/unverify AS 12.3
GET /api/downloads/latest — 12.10
POST /api/flash/1voucher/disburse S 12.9
POST /api/flash/1voucher/purchase S 12.9
POST /api/flash/1voucher/redeem S 12.9
POST /api/flash/1voucher/refund S 12.9
POST /api/flash/1voucher/reverse S 12.9
POST /api/flash/cash-out-pin/cancel S 12.9
POST /api/flash/cash-out-pin/lookup S 12.9
POST /api/flash/cash-out-pin/purchase S 12.9
POST /api/flash/cash-out-pin/reverse S 12.9
POST /api/flash/cellular/pinless/purchase S 12.9
POST /api/flash/eezi-voucher/purchase S 12.9
POST /api/flash/eezi-voucher/reverse S 12.9
POST /api/flash/flash-pay/disburse S 12.9
POST /api/flash/flash-pay/purchase S 12.9
POST /api/flash/flash-token/purchase S 12.9
POST /api/flash/flash-token/reverse S 12.9
POST /api/flash/gift-vouchers/purchase S 12.9
POST /api/flash/gift-vouchers/reverse S 12.9
POST /api/flash/prepaid-utilities/purchase S 12.9
GET /api/flash/product S 12.9
GET /api/flash/products S 12.9
GET /api/flash/zakhaapay/getproducts/{category} S 12.9
GET /api/flash/zakhaapay/product S 12.9
GET /api/gateway/admin/checkout-sessions/{reference} GA 12.13
GET, POST /api/gateway/admin/fee-plans GA 12.13
GET, PATCH /api/gateway/admin/fee-plans/{id} GA 12.13
GET, POST /api/gateway/admin/merchants GA 12.13
GET, PATCH /api/gateway/admin/merchants/{id} GA 12.13
POST /api/gateway/admin/merchants/{id}/api-keys GA 12.13
DELETE /api/gateway/admin/merchants/{id}/api-keys/{keyId} GA 12.13
GET /api/gateway/admin/merchants/{id}/checkout-sessions GA 12.13
POST /api/gateway/admin/merchants/{id}/domains GA 12.13
DELETE /api/gateway/admin/merchants/{id}/domains/{domain} GA 12.13
POST /api/gateway/admin/merchants/{id}/signing-secrets/rotate GA 12.13
GET /api/gateway/admin/merchants/{id}/webhook-events GA 12.13
GET /api/gateway/admin/review-queue GA 12.13
GET /api/gateway/admin/review-queue/count GA 12.13
POST /api/gateway/admin/review-queue/{reference}/notes GA 12.13
POST /api/gateway/admin/review-queue/{reference}/reopen GA 12.13
POST /api/gateway/admin/review-queue/{reference}/resolve GA 12.13
POST /api/gateway/admin/sandbox/sessions/{ref}/callback GA 12.13
POST /api/gateway/admin/webhook-events/{eventId}/resend GA 12.13
GET /api/helpdesk/customers/{ref} P:helpdesk 12.4
GET /api/helpdesk/customers/{ref}/logs P:helpdesk 12.4
GET /api/helpdesk/customers/{walletId}/summary P:helpdesk 12.4
GET /api/helpdesk/customers/{walletId}/transactions P:helpdesk 12.4
GET /api/helpdesk/transactions/{ref} P:helpdesk 12.4
GET /api/helpdesk/wallets/{walletId} P:helpdesk 12.4
PATCH, DELETE /api/kyc/{id} S 12.3
GET /api/kyc/getbyid/{id} S 12.3
GET /api/kyc/getbyuid{uid} S 12.3
POST /api/kyc/kyc S 12.3
GET /api/links/Tsandcs — 12.10
GET /api/mobile/display-ads/events S 12.10
POST /api/mobile/display-ads/events S 12.10
GET /api/mobile/display-ads/home S 12.10
POST /api/notes/user/{userId} AS 12.3
GET /api/notes/user/{userId} AS 12.3
PUT /api/notes/{noteId} AS 12.3
GET /api/notifications S 12.10
PUT /api/notifications/{deviceId}/notifications S 12.10
PUT /api/notifications/preferences S 12.10
PUT /api/notifications/push-token S 12.10
PUT /api/notifications/read-all S 12.10
PUT /api/notifications/read/{id} S 12.10
POST /api/payat/account/enquiry P:payat 12.8
POST /api/payat/echoTest P:payat 12.8
POST /api/payat/transaction/authorization P:payat 12.8
POST /api/payat/transaction/completion P:payat 12.8
POST /api/payat/transaction/void P:payat 12.8
POST /api/payment/callback/register S 12.6
POST /api/payment/callback/unregister S 12.6
GET /api/payment/can-pay/{wallet_id} S 12.6
GET /api/payment/getBeneficiaries/{wallet_id} S 12.6
POST /api/payment/initiate-payment S (OTP) 12.6
GET /api/qr/lookup P:eft_corp 12.7
POST /api/qr/lookup S 12.7
GET /api/qr/lookup/{walletId}/qr J 12.7
POST /api/reconciliation/apply-topup AS 12.11
POST /api/reconciliation/requestpop AS 12.11
GET /api/reconciliation/unmatched-credits AS 12.11
GET /api/reconciliation/unmatched-credits/{id}/possible-users/ AS 12.11
POST /api/SendEmail/Custom — (none!) 12.10
GET, POST /api/settings ({uid} for GET) S 12.10
PUT /api/settings/{id} S 12.10
GET /api/settings/getbyid{id} S 12.10
GET /api/settings/version — 12.10
POST /api/settings/version A 12.10
POST /api/stp/callback — (EFT Corp, encrypted) 12.7
POST /api/stp/finalizestp S 12.7
POST /api/stp/initiate/xpay — (rate-limited) 12.7
POST /api/stp/initiatestp S 12.7
GET /api/stp/nfc/tags/{walletId} S 12.7
POST /api/stp/nfc/tags/{tagId}/abandon S 12.7
POST /api/stp/nfc/tags/{tagId}/activate S 12.7
POST /api/stp/nfc/tags/{tagId}/deactivate S 12.7
POST /api/stp/nfc/tags/{tagId}/reactivate S 12.7
POST /api/stp/nfc/tags/{tagId}/revoke S 12.7
POST /api/stp/nfc/tags/generate S 12.7
GET /api/stp/nfc/tags/resolve/{tagId} — 12.7
POST /api/stp/payment/callback — 12.7
POST /api/stp/refund-webhook/{refundId}/{token} — (per-refund token) 12.7
GET /api/stp/resolve/merchant/{zakhaaId} — (rate-limited) 12.7
GET /api/stp/status/{paymentId} — 12.7
GET /api/support/tawk-login S 12.10
GET /api/topup/{id} S 12.4
PUT /api/topup/{id} SuperAdmin 12.4
GET /api/topup/getbywallet/{walletid} S 12.4
POST /api/topup/top-up SuperAdmin 12.4
GET /api/transactions AS 12.4
GET /api/transactions/{rail}/{id} AS 12.4
GET /api/transactions/user/{userId} S 12.4
GET /api/user/all AS 12.3
POST /api/user/add/business S 12.3
POST /api/user/confirm S 12.3
DELETE /api/user/delete/{uid} S 12.3
GET /api/user/nfc/tags/{walletId} AS 12.3
GET /api/user/unverified/Users AS 12.3
PUT /api/user/update/email/{userId} S (OTP) 12.3
PUT /api/user/update/password/{userId} S (OTP) 12.3
PUT /api/user/update/phone/{userId} S (OTP) 12.3
POST /api/user/updateotp S 12.3
PUT /api/user/updatesignin/{userId} — (OTP) 12.3
POST /api/user/upload/{filepath} SA 12.3
GET /api/user/users AS 12.3
GET, PUT /api/user/{userId} AS 12.3
GET /api/user/{userId}/accounts/{walletId}/transactions AS 12.3
GET /api/user/{userId}/analytics AS 12.3
PUT /api/user/{userId}/reactivate AS 12.3
PUT /api/user/{userId}/suspend AS 12.3
GET /api/vouchers/{voucher} S 12.8
GET /api/vouchers/all/{walletId} S 12.8
POST /api/vouchers/create S 12.8
POST /api/vouchers/redeem S 12.8
POST /api/vouchers/reverse S 12.8
POST /api/wallet SA 12.4
GET /api/wallet/{uid} or /api/wallet/{walletId} S 12.4
DELETE /api/wallet/{id} SA 12.4
GET /api/wallet/{walletId}/pay-at-barcode S 12.4
GET /api/wallet/analytics AS 12.4
POST /api/wallet/generate/{count} SA 12.4
POST /api/wallet/import SA 12.4
POST /api/webhooks/validate M 12.12
GET /api/WebsitePopUpMessages (/{id}, /active, /active/{target}, /active/android) — (CORS any) 12.10
POST /api/WebsitePopUpMessages A 12.10
POST /api/withdrawal S (OTP ≥ R500) 12.5
POST /api/withdrawal/{filePath} SA 12.5
POST /api/withdrawal/BBM SA 12.5
GET /api/withdrawal/getBeneficiaries/{wallet_id} S 12.5
GET /api/withdrawal/getbyid/{id} S 12.5
GET /api/withdrawal/getbywalletid{walletid} S 12.5
POST /api/zapper/finalize S 12.8
POST /api/zapper/initiate S 12.8
GET /healthz — 3.1
SignalR /notifHub partly open 6.8

16. Security status and operational considerations

16.1 Open issues

Reviewed Nov 2026 after the security fixes (§16.2). Each needs an owner.

Severity Issue Where Why it matters Suggested fix
High Open email relay. POST /api/SendEmail/Custom (anonymous) Anyone can send email with any From, body and attachments through Zakhaa's SMTP account. Remove it or require admin auth. (Left as is for now, by decision.)
Medium Leftover credentials in git. appsettings.Production.json (NedbankApiSettings sandbox client ID, secret and certificate password), Certificates/Sandbox.pfx The Nedbank integration is commented out, but the sandbox credentials are readable by anyone with repository access. (The Google and Tawk keys were rotated and moved to Secrets Manager in Nov 2026.) Remove the NedbankApiSettings credentials and the certificate from git; keep them in Secrets Manager when Nedbank is enabled.
Medium One signing key for every kind of JWT (app, admin, web, POS terminal sessions). AuthenticationExtensions, TokenProvider, JwtToken, TerminalController Tokens are interchangeable for plain [Authorize], and anything holding the key (e.g. the POS service) can mint tokens. Ownership and role checks now limit the damage. Separate keys or audiences per token type (needs the dashboard and POS service to change).
Medium In-memory security state. OTPs, OTP attempt counters, device replay cache, device challenges Lost on restart; breaks with more than one instance. Redis or database-backed state before scaling out.
Medium POS bootstrap calls a test service. TerminalController Unknown merchants are fetched from the test service by default, with the session token in the query string. Set Terminal:BootstrapUrl for production; move the token to a header.
Medium Legacy sensitive data still in plain text. Old ZakhaaVoucher.PIN rows (new ones are encrypted), old CashSendTransactions.CashSendPin rows, VasTransactions.Raw, UserKycs.RawPayload, KYC_Profiles images A database leak exposes them. One-off clean-up (UPDATE "CashSendTransactions" SET "CashSendPin" = NULL), re-save old voucher PINs to encrypt them, minimise raw payloads, move images to S3.
Low Voucher redemption needs only the number (by decision). ZakhaaVoucherRepository.RedeemAsync Anyone who sees a voucher number can redeem it into their own wallet. Optional PIN check if the business wants it.
Low /api/auth/otp returns the user's uid for a phone number or email. AuthController.OTP Leaks account IDs. Ownership checks now stop them being used against other users, but it still confirms who has an account. Stop returning it once the app no longer needs it.
Low No fraud scoring on Send Zakhaa and vouchers. WithdrawalRepository, ZakhaaVoucherRepository Only the reserve-based rails call the fraud model. Business decision. Decide whether to score them (fails closed if the model is down).
Low Error details in responses. Many older controllers return ex.Message May reveal internals. Return generic messages; log the details.
Low Anyone can unsubscribe an external contact. POST /api/admin/campaigns/external/unsubscribe (anonymous) By design for public unsubscribe links, but not tied to a signed link. Use signed unsubscribe tokens.

16.2 Fixed in Nov 2026

Area What changed
Money creation POST /api/topup/top-up and PUT /api/topup/{id} are SuperAdmin only and audited; amounts can't be edited. PUT /api/wallet/{id} (balance overwrite) removed.
Vouchers Create and reverse use the caller's own wallet; redeem pays into the caller's own wallet; PINs encrypted at rest (AES-GCM), shown as before.
CashSend Webhook requires the transaction's X-Callback-Token (401 otherwise); wallet must belong to the caller; collection PINs no longer stored. (No OTP, by decision.)
App settings POST /api/settings/version is admin only, with an allow-list of keys and https update URLs.
Ownership Wallets, top-ups, withdrawals, beneficiaries, PayShap eligibility, settings, legacy KYC (no self-verification), documents, transactions, addresses, NFC tags, add-business, admin profiles. Platform-wide data (/api/transactions, wallet analytics, KPI page) is staff only.
Maintenance endpoints Wallet-ID generate/import, POST /api/withdrawal/{filePath} (was an arbitrary file read), POST /api/user/upload/{filepath} (bulk user creation), POST /api/withdrawal/BBM (was anonymous: fake payments on a shop screen) are SuperAdmin only.
Tokens App refresh tokens have their own audience and can't be used as access tokens.
OTPs Single use, 5 wrong guesses discard the code, constant-time compare; test codes only for the store-review account and only with Otp:AllowTestAccounts; Send Zakhaa and PayShap OTPs tied to the logged-in user.
Brute force auth rate limit (20/min per IP and endpoint) on login, OTP, registration, device, admin login and token endpoints.
PINs PBKDF2 (210,000 iterations), legacy hashes upgraded at next login (so don't roll back to older code).
Enumeration Login gives one message for an unknown number or a wrong PIN.
Encryption AES-GCM for new values; old CBC values still decrypt.
Ledger Promo credits write a ledger row; the Send Zakhaa receiver's opening balance comes from the ledger.
Secrets Google Geocoding and Tawk keys rotated and moved to Secrets Manager (GeocodingApiKey, TawkApiKey).
Misc Manual top-ups notify the user directly; login no longer calls the production OTP URL; IP geolocation over HTTPS (ipwho.is); 500 responses no longer return stack traces (KYC, top-ups).
Corrections The Scan to Pay tip was never a bug (charged inside DbWalletLedger.ReserveAsync), and POS terminal login never unlinked the owner's phone.

16.3 Operational gotchas

  • Migrations are manual (§3.5). Everything up to RefundWebhookToken is applied in production.
  • deploy-V2.sh runs docker volume prune -f, which deletes volumes not attached to a running container. uat-deploy.sh deliberately doesn't.
  • Wallet.balance is a cache. Fix balances through the ledger, never by editing balance.
  • HTTP 200 doesn't mean success on older endpoints: check success in the body.
  • The device signature uses the path without /v2. A client that signs /v2/api/... gets "Invalid device signature".
  • EFT Corp must get a 200 within 45 seconds on /api/stp/callback, or it reverses the payment. Processing is queued for this reason; keep the synchronous part small.
  • EFT Corp REVERSED can mean a failed reversal: only bankResponse == "00" means it happened. Reversals we didn't request still claw back regardless (an OPS: warning is logged).
  • Debit cards can't be refunded through doRefund (error 445). Only a full same-day reversal works.
  • Rand vs cents: older endpoints use decimal rand; the gateway, refunds, cash devices and Flash requests use integer cents.
  • SAST: daily limits, the trust-tier job and same-day reversals use Africa/Johannesburg.
  • Disabled controllers: AmlController, CardController and DisputeController are commented out. AnalyticsController, CashoutController, PublicController and ServiceController have no actions.
  • Don't roll back below Nov 2026: PIN hashes are upgraded at login (§6.4).
  • Logs starting OPS: need a human: unconfirmed refunds, odd EFT Corp states, failed payment notifications.

17. How data is handled

17.1 What personal and financial data we store, and how

Data Where How it's protected Notes
Name, surname, email, phone, ID type and number, date of birth Users Database access only Phone and email are the login and OTP keys.
Location (area, postal code) Users.location, postal_code Derived from GPS at registration (Google Geocoding) or from the IP address at login (ipwho.is, HTTPS).
App PIN Users.password, salt PBKDF2-SHA256 (210,000 iterations), 32-byte salt; legacy SHA-256 hashes upgraded at next login 6–8 characters, must include a digit.
Admin password and 2FA Identity DB Identity PBKDF2; TOTP key AES-encrypted; recovery codes hashed
Admin refresh tokens Identity DB RefreshTokens SHA-256 hash only Rotated on use.
OTPs In-memory cache (5 minutes) Not persisted; single use; 5 wrong guesses discard the code Lost on restart; not shared between instances.
Device public keys and push tokens DeviceInfo One active device per user; old ones soft-deleted.
Didit KYC result UserKycs Database access only Includes the full raw Didit payload (identity document data, scores).
KYC document files S3 (Documents.S3Key) Virus-scanned, size and type limits Metadata in Documents.
Legacy KYC images KYC_Profiles.id_doc, selfie (bytes) Stored directly in the database Uploaded as base64 through /api/kyc.
Bank details for PayShap PayshapBeneficiary Account number, type, branch, holder, bank.
Business details Businesses Registration number, address, MCC, bank account number.
Cash-send recipient data CashSendTransactions Mobile number, name, note, withdrawal number. The collection PIN is no longer stored (older rows may hold one).
Zakhaa voucher PIN ZakhaaVoucher.PIN AES-GCM encrypted at rest, decrypted on read (older rows plain text) Returned by the voucher lookup and lists, as the app expects.
VAS provider responses VasTransactions.Raw May contain voucher PINs and tokens.
Card data Never stored Card payments happen in EFT Corp / the wallet apps; we receive only BIN and last 4 in EFT Corp's encrypted notifications (not stored).
Cash-device withdrawal codes CashDeviceWithdrawalRequests Salted hash; 3 attempts
Partner client secrets OAuthClients BCrypt
Merchant API keys / signing secrets Gateway tables SHA-256 / AES-encrypted
Client IP addresses Rail tables (IpAddress) Recorded on reserved payments.
Errors ErrorLogs.message Free text, may include user IDs; read by the helpdesk.
Admin actions AuditLogs Admin, action, details, target.
User activity UserActivityLogs
Login attempts AuthenticationInfo User, device, method (PIN, Biometrics), success or failure reason.

17.2 Third parties that receive data

Party What they receive
EFT Corp (Scan to Pay) Payment amounts and references; refund and reversal requests.
Zakhaa middlewares (payshap.zakhaapay.com, eftcorp.zakhaapay.com, zapper.zakhaapay.com) Bank account details, cash-send recipients, QR payloads, float top-ups.
Flash VAS purchases (phone numbers, meter numbers, amounts).
Didit Identity verification session (the user's ID document and selfie, captured by Didit).
Google Geocoding GPS coordinates.
ipwho.is User IP addresses (HTTPS).
Expo Push tokens and notification texts (which include amounts).
SMTP2GO Email addresses, OTPs, receipts.
AWS (S3, Secrets Manager) KYC documents, campaign media, secrets.
Fraud model (/fraud/predict) Transaction features (amount, timing, wallet averages).
Tawk.to Support chat identity (HMAC-signed user ID).

17.3 Retention and deletion

  • Users: soft-deleted (is_deleted, Deleted_At). Nothing is purged automatically.
  • Devices: soft-deleted when replaced or unlinked.
  • Financial records (Topups, Withdrawal, ledger, rail tables) are never deleted by the app.
  • Vouchers expire after a year and are refunded by SetExpiredAsync.
  • OTPs and device challenges expire from memory after 5 minutes. Device replay protection keeps request IDs for 10 minutes.

17.4 Balances and money rules (summary)

See §7. In short:

  • the ledger is the source of truth and Wallet.balance is a cache;
  • debits lock the wallet and check limits, tiers, holds and (for reserve-based rails) the fraud model;
  • fees carry 15% VAT;
  • the daily and monthly counters reset daily and monthly (SAST).

Two places still compute from the cached balance or skip the ledger: the Send Zakhaa receiver credit (opening balance from Wallet.balance) and the promo credit (no ledger row).


18. Configuration reference

All non-secret settings, from appsettings.{Environment}.json. Override any of them with an environment variable using __ for : (for example EftCorp__BaseUrl). Secrets are in §3.2.

Key Production value Used by Notes
Logging:LogLevel:* Information / Warning for ASP.NET Core Logging
AllowedHosts * Host filtering
EftCorp:BaseUrl https://rags.oltio.co.za/rags/ EftCorpClient UAT: https://ragstest.oltio.co.za/pluto/. Paths are relative (purchase/…).
EftCorp:RefundWebhookBaseUrl https://api.wallet.zakhaapay.com/v2/api/stp/refund-webhook PaymentRefundService Each bank refund gets {base}/{refundId}/{token}.
ScanToPayMiddleware:BaseUrl https://eftcorp.zakhaapay.com/ ScanToPayMiddlewareService
ScanToPayMiddleware:InitiatePath, FinalizePath, XPayPaymentPath, LookUpPath, QRPaymentPath, GetStatusPath, GetBalancePath /api/stp/initiate, /api/stp/finalize, /api/stp/initiate/xpay, /api/PayQrLookup, /api/PayQrLookup/qrPayment, /api/stp/status, /api/TenantWallet/details same
ScanToPayMiddleware:TimeoutSeconds 120 same
CashSend:CallbackUrl https://api.wallet.zakhaapay.com/v2/api/cashsend/webhook CashSendRepository Sent to TransMS with each cash send.
Flash:BaseUrl, Flash:TokenUrl https://api.flashswitch.flash-group.com (+ /token) FlashClientService The Flash account number comes from secrets.
Jwt:Web:ExpirationInMinutes, RefreshTokenExpirationDays 5, 7 Web JWT options The effective values come from the JWT secrets.
Otp:AllowTestAccounts true OtpSmsService, AuthController Enables the store-review account's fixed codes (§6.4).
Terminal:BootstrapUrl (unset → test service) TerminalController Set it to the production POS service.
CampaignRateLimits:PushMessagesPerSecond 500 Campaign push processor
CampaignRateLimits:EmailMessagesPerMinute, EmailRateLimitPer10Mins 100, 500 Campaign email processor
NedbankApiSettings:* Sandbox / QA / Production blocks (Nedbank integration commented out) Holds credentials in git; remove (§16.1).
ASPNETCORE_PATHBASE (env) /v2 Pipeline UAT: /wallet/uat.
ASPNETCORE_URLS (env) http://+:5003 Kestrel UAT: port 5000.

Constants in code (not configurable):

  • CheckoutRules.PortalOrigin = https://pay.zakhaapay.com (the checkout page);
  • the HTTP base addresses in HttpClientExtensions (PayShap and CashSend middleware https://payshap.zakhaapay.com/, Zapper https://zapper.zakhaapay.com/, Didit, the fraud model);
  • Pay@ limits R10–R15,000;
  • trust-tier caps and hold durations (§7.4–7.5);
  • the OTP lifetime (5 minutes) and attempt limit (5).

19. Operations runbook

Common tasks, with the exact endpoint or table to use. Paths are shown without /v2. "Admin token" means an admin JWT from /api/admin-auth/login with the role shown.

19.1 Customers

Task How
Find a customer Dashboard: GET /api/user/all?search= (admin+support), then GET /api/user/{userId}. Helpdesk tool: GET /api/helpdesk/customers/{ref} (by wallet ID, phone or email).
See a customer's transactions GET /api/user/{userId}/accounts/{walletId}/transactions (admin+support).
Suspend or reactivate PUT /api/user/{userId}/suspend or /reactivate (admin+support). Sets is_active; audited.
Unfreeze after a KYC mismatch A Didit mismatch sets is_active = false and need_review = true (13.18). Review the documents, then PUT /api/user/{userId}/reactivate, and approve with POST /api/documents/{userId}/approve if the identity is confirmed.
Verify a user manually POST /api/documents/{userId}/approve (all their documents must be Uploaded) → is_verified = true. Reject one with POST /api/documents/{userId}/reject { documentId, userEmail, reason } (the user is emailed).
Add an internal note POST /api/notes/user/{userId} (admin+support).
Change a customer's spending tier No endpoint. Set Wallet.trust_tier_override (0 New … 3 Trusted) or Wallet.trust_tier_frozen = true in the database (§7.4). The daily job skips overridden and frozen wallets.
Change platform limits No endpoint. Edit TransactionLimit rows (Daily, Monthly, Withdrawal, Unverified, status = true).
Change a fee No endpoint. Edit Fees (code, amount, pct, status). Codes: ZAKPEER, ZAKVOUCHER, S2P_DEPOSIT, DEPOSIT, DEPOSIT/TRANSFER, 1VOUCHERREDEEM, CASHDEVICE_DEPOSIT, CASHDEVICE_WITHDRAWAL, plus the codes used in gateway fee plans. VAT is always 15% of the fee.

19.2 Money

Task How
Credit an unmatched bank deposit 1. GET /api/reconciliation/unmatched-credits?search= (admin+support). 2. GET …/unmatched-credits/{id}/possible-users/. 3. POST /api/reconciliation/apply-topup with { walletId, amount, fee, vat, reference, email, statementId, name }. The wallet is credited with a manual_topup ledger row, the action is audited and the user notified. Duplicate references are rejected.
Ask a customer for proof of payment POST /api/reconciliation/requestpop (emails them).
Exceptional manual credit POST /api/topup/top-up (SuperAdmin), audited as MANUAL_TOPUP_CREATE. Prefer apply-topup. Never edit Wallet.balance directly (§7.2).
Refund a payment POST /api/admin/refunds { ref?, topupId?, amountCents?, reason }. Wallet refunds complete at once; bank refunds stay PENDING until EFT Corp confirms (13.4–13.5).
A bank refund is stuck in PENDING POST /api/admin/refunds/{id}/check asks EFT Corp. If it still can't be resolved, check the EFT Corp portal, then POST /api/admin/refunds/{id}/settle { outcome: "succeeded"|"failed", note }.
Investigate a card payment Find the StpCallbacks row by transaction ID, then the Topups row with reference {ref}:{transactionId}. Search the logs for OPS: lines.

19.3 Payment gateway (merchants)

Task How (gateway/refund admin = SuperAdmin or Admin)
Onboard a merchant POST /api/gateway/admin/merchants, add domains (POST …/merchants/{id}/domains), issue a test key (POST …/merchants/{id}/api-keys). After KYB: PATCH …/merchants/{id} with status live and a fee plan. Full contract: docs/admin-dashboard-gateway.md.
Revoke a leaked API key DELETE /api/gateway/admin/merchants/{id}/api-keys/{keyId}, then issue a new one.
Rotate a webhook signing secret POST …/merchants/{id}/signing-secrets/rotate. The old secret stays valid for 24 hours.
A merchant didn't receive a webhook GET …/merchants/{id}/webhook-events (attempts and response codes), then POST /api/gateway/admin/webhook-events/{eventId}/resend.
Work the review queue GET /api/gateway/admin/review-queue, add notes, resolve or reopen. Flagged sessions aren't expired automatically.
Fee plans GET/POST /api/gateway/admin/fee-plans, PATCH …/fee-plans/{id}.

19.4 Partners and access

Task How
Add a partner API client POST /api/admin/api-clients (SuperAdmin) { clientId, clientSecret, name, scope, tokenLifetimeSeconds }. Scope payat, eft_corp, helpdesk or zcashdeposit.
Cut off a partner immediately PATCH /api/admin/api-clients/{clientId}/revoke-tokens (existing tokens stop working at once), or /suspend. Rotate with /rotate-secret.
Unlock an admin POST /api/admin-auth/unlock-account (Admin or SuperAdmin).
Change an admin's role or status PUT /api/admin/{id}/role or /status (SuperAdmin).
Change the store-review test account Edit fixedOtpUids in Utilities/OtpSmsService.cs and the special-user GUID in AuthController.BindDevice. Both need Otp:AllowTestAccounts = true.

19.5 App and platform

Task How
Force an app update POST /api/settings/version (Admin or SuperAdmin) with { key: "LatestAppVersion", value: "2.1.0" }, { key: "ForceUpdate", value: "true" }, and the UpdateUrlAndroid / UpdateUrlIOS https URLs.
Top up the pool of wallet IDs POST /api/wallet/generate/{count} then POST /api/wallet/import (SuperAdmin). New wallets take the next unassigned ID.
Send a campaign Web dashboard: create (POST /api/admin/campaigns), estimate reach, then POST /api/admin/campaigns/{id}/queue. Only Push and Email are implemented.
Change the schema §3.5.
Deploy deploy-V2.sh (production) or uat-deploy.sh (UAT), after applying any new migrations.
Check the service is up GET /healthz.

20. Request and response examples (every endpoint)

Every endpoint, with what it does, links to its reference table (§12) and the flow that explains it (§13, or §6 for sign-in), its permissions, parameters, an example request body and an example successful response. Endpoints are grouped by area and sorted by path; search for the path (for example cashsend/initiate) to jump to one.

20.0 How to read these examples

How the examples were produced. They're generated from the code: request bodies from the actual request classes, and responses from the declared result types or the method that produces the data. Responses that couldn't be traced automatically were checked by hand. The values are illustrative ("…" means any value of that type); the keys and structure are what the code uses.

JSON naming.

  • Controllers and the feature slices read and write JSON with Newtonsoft.Json.
  • Response keys are camelCase versions of the C# property names (PIN → pin, VAT → vat, ExpoPushToken → expoPushToken). Names that already contain underscores, such as wallet_id, are kept as they are.
  • Request keys are matched case-insensitively against the C# property names.
  • [JsonPropertyName] attributes are ignored, because they belong to System.Text.Json. Only Newtonsoft's [JsonProperty] renames a key. For example, the Flash request property AmountInCents carries [JsonPropertyName("amount")], but the key the API actually binds is amountInCents.

Paths. Paths are shown without the path base; prefix them with /v2 in production (§3.1).

Auth labels:

Label Meaning
Strict (user JWT + device signature) [AuthorizeStrict]: app user token plus a device signature from the same user (§6.2), and the ownership checks of §6.3
Device signature [AuthorizeDevice]: device signature only
Roles: … Admin JWT with one of the roles listed
Any JWT Plain [Authorize]
Web JWT [AuthorizeWebJwt] (campaign dashboard)
Policy ApiClient:… / Partner token OAuth client-credentials token with the matching scope (§6.6)
Merchant API key sk_test_… / sk_live_… (§6.7)
None No authentication; see the endpoint's own verification (§6.10)
rate limit … The named rate-limit policy applies (§6.11)

The four response shapes you'll see:

// 1. ServerResponse: older app endpoints. Often HTTP 200 even on failure, so check "success".
{ "success": true, "message": "", "data": { … } }
{ "success": false, "message": "Invalid OTP", "data": null }

// 2. Result<T>: newer endpoints, gateway, refunds, cash devices. Proper HTTP status codes.
{ "isSuccess": true, "data": { … }, "error": null }
{ "isSuccess": false, "data": null, "error": { "code": "validation_error", "description": "…", "metadata": null } }

// 3. Dictionary: VAS, Zapper, Scan to Pay payments
{ "success": true, "data": { … } }
{ "success": false, "message": "…" }

// 4. A bare object or list (some older GET endpoints)

Paged lists (page, pageSize → …):

{ "items": [ … ], "totalRecords": 120, "totalPages": 6, "currentPage": 1, "pageSize": 20, "hasNextPage": true, "hasPreviousPage": false }

Errors common to every endpoint:

Status When Body
401 Missing or invalid token, a failed device signature, or a revoked partner token Empty, or { "isSuccess": false, "error": { "code": "unauthorized" \| "InvalidToken" \| "TokenRevoked" \| "TokenExpired", … } }
403 Wrong role, or the wallet/record doesn't belong to the caller (§6.3) Empty
404 Unknown record (not_found on newer endpoints) Varies
409 Conflict (gateway order conflicts, refund in progress, …) Result error
422 Validation error on newer endpoints (validation_error, …) Result error
423 Admin account locked Result error
428 Admin must set up two-factor login Result error TwoFactorSetupRequired
429 Rate limited { "isSuccess": false, "error": { "code": "rate_limited", … } }, Retry-After: 60
500 Unexpected server error Older endpoints: { "message": "An unexpected error occurred." }

Gateway-specific error codes are listed in docs/merchant-integration-guide.md §9 and docs/admin-dashboard-gateway.md.

20.1 Auth, devices and POS terminals

POST /api/auth/device/bind

What it does: Binds a phone to the user after checking the OTP that login emailed (keyed by the device ID). Registers the device's RSA public key, deactivates the user's previous device and pushes "Device unlinked" to it, then returns tokens and the full user data. The store-review account can use 256789 when Otp:AllowTestAccounts is on.

See: §12.1 · §6.4

Auth: None; rate limit auth

Request (DeviceRegisterDTO):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "deviceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "publicKey": "…",
  "otp": "482913",
  "deviceType": "Payment",
  "model": "…",
  "os": "…",
  "brand": "…",
  "localVersion": "…"
}

Response 200:

{
  "success": true,
  "message": "<expo push token>",
  "data": {
    "user": {
      "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "name": "Thandi",
      "surname": "Mokoena",
      "phone": "0821234567",
      "expoPushToken": "<token>",
      "idNumber": "9001015009087",
      "email": "thandi@example.com",
      "dob": "…",
      "roleId": 1,
      "is_verified": true,
      "needs_Review": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "isActive": true
    },
    "accessToken": "<token>",
    "refreshToken": "<token>",
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "businesses": [{
      "name": "Thandi",
      "busID": "…",
      "wallet": {
        "wallet_id": "9471452626",
        "balance": 150.00,
        "availableBalance": 150.00,
        "dailySpent": 1.00,
        "currency": "ZAR",
        "status": "Completed",
        "transactions": [ { … } ],
        "vouchers": [ { … } ]
      },
      "isVerified": true,
      "streetAddress": "…",
      "suburb": "Braamfontein",
      "city": "Johannesburg",
      "province": "Gauteng",
      "postalCode": "2001",
      "mcc": "…",
      "type": "Payment",
      "email": "thandi@example.com"
    }],
    "transactions": [{
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    }],
    "additionalData": { },
    "notificationPreference": {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "globalOptIn": true,
      "smsEnabled": true,
      "pushEnabled": true,
      "emailEnabled": true,
      "inAppEnabled": true,
      "systemEnabled": true,
      "marketingEnabled": true,
      "promotionsEnabled": true,
      "remindersEnabled": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z"
    }
  }
}

POST /api/auth/device/challenge

What it does: First step of signing in on an already-bound phone. For PIN sign-in it re-checks the PIN; it then returns a random 32-byte challenge (cached 5 minutes) for the device to sign. Failed attempts are logged in AuthenticationInfo.

See: §12.1 · §6.4

Auth: Device signature; rate limit auth

Request (DeviceLoginDTO):

{
  "deviceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "authType": "Payment",
  "pin": "1234"
}

Response 200:

{
  "success": true,
  "message": "",
  "data": …
}

GET /api/auth/device/status/{DeviceId}

What it does: Tells the app whether this device is currently linked to an account (isLinked).

See: §12.1 · §6.4

Auth: None

Route: DeviceId (string)

Response 200:

{
  "success": true,
  "message": "",
  "data": { "isLinked": … }
}

POST /api/auth/device/unlink

What it does: Soft-deletes the calling device's binding (IsActive=false, IsDelete=true). The user must bind again with an OTP.

See: §12.1 · §6.4

Auth: Device signature

Response 200:

{
  "success": true,
  "message": "",
  "data": null
}

POST /api/auth/device/verify

What it does: Second step of signing in on a bound phone: verifies the RSA signature of the challenge, updates the app version and last-seen time, and returns tokens and the full user data.

See: §12.1 · §6.4

Auth: Device signature; rate limit auth

Request (DeviceVerifyDTO):

{
  "deviceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "authType": "Payment",
  "signedChallenge": "…",
  "localVersion": "…"
}

Response 200:

{
  "success": true,
  "message": "<expo push token>",
  "data": {
    "user": {
      "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "name": "Thandi",
      "surname": "Mokoena",
      "phone": "0821234567",
      "expoPushToken": "<token>",
      "idNumber": "9001015009087",
      "email": "thandi@example.com",
      "dob": "…",
      "roleId": 1,
      "is_verified": true,
      "needs_Review": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "isActive": true
    },
    "accessToken": "<token>",
    "refreshToken": "<token>",
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "businesses": [{
      "name": "Thandi",
      "busID": "…",
      "wallet": {
        "wallet_id": "9471452626",
        "balance": 150.00,
        "availableBalance": 150.00,
        "dailySpent": 1.00,
        "currency": "ZAR",
        "status": "Completed",
        "transactions": [ { … } ],
        "vouchers": [ { … } ]
      },
      "isVerified": true,
      "streetAddress": "…",
      "suburb": "Braamfontein",
      "city": "Johannesburg",
      "province": "Gauteng",
      "postalCode": "2001",
      "mcc": "…",
      "type": "Payment",
      "email": "thandi@example.com"
    }],
    "transactions": [{
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    }],
    "additionalData": { },
    "notificationPreference": {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "globalOptIn": true,
      "smsEnabled": true,
      "pushEnabled": true,
      "emailEnabled": true,
      "inAppEnabled": true,
      "systemEnabled": true,
      "marketingEnabled": true,
      "promotionsEnabled": true,
      "remindersEnabled": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z"
    }
  }
}

GET /api/auth/kyc/verification/start/{uid}

What it does: Creates a Didit identity-verification session for the user and returns its link (13.18).

See: §12.1 · §13.18

Auth: Strict (user JWT + device signature)

Route: uid (string)

Response 200:

{
  "success": true,
  "message": "",
  "data": "…"
}

POST /api/auth/kyc/verification/webhook

What it does: Didit's result callback. HMAC-checked; approved results are matched against the profile (ID number, date of birth, names) and set is_verified, or freeze the account on a mismatch (13.18).

See: §12.1 · §13.18

Auth: None

Response 200: empty body.

POST /api/auth/login

What it does: Checks the phone number and PIN, then emails a device-verification OTP keyed by deviceId. No token is issued here; the app continues with device/bind. Same error message for an unknown number or a wrong PIN.

See: §12.1 · §6.4

Auth: None; rate limit auth

Request (LoginDTO):

{
  "phone_number": "0821234567",
  "password": "••••••",
  "deviceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}

Response 200:

{
  "success": true,
  "message": "OTP sent successfully.",
  "data": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}

POST /api/auth/otp

What it does: Sends an OTP by email for registration (EmailVerification), device verification, forgot-password or profile changes. Returns the user's ID (used by the app in later steps).

See: §12.1 · §6.4

Auth: None; rate limit auth

Request (OTPRequest):

{
  "userID": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "email": "thandi@example.com",
  "phone": "0821234567",
  "emailType": "OtpVerification"
}

Response 200:

{
  "success": true,
  "message": "",
  "data": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}

POST /api/auth/register

What it does: Registers a personal user: checks the PIN rules and the OTP, creates the user, a personal wallet (next free Zakhaa ID), settings and notification preferences in one transaction, applies an optional promo credit, binds the device and returns tokens.

See: §12.1 · §6.4

Auth: None; rate limit auth

Request (RegisterDTO):

{
  "name": "Thandi",
  "surname": "Mokoena",
  "email": "thandi@example.com",
  "phone_number": "0821234567",
  "password": "••••••",
  "idNumber": "9001015009087",
  "promoCode": "<code>",
  "dob": "…",
  "idType": "Payment",
  "uid": "…",
  "otp": "482913",
  "latitude": 1.00,
  "longitude": 1.00,
  "deviceInfo": {
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "deviceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "publicKey": "…",
    "otp": "482913",
    "deviceType": "Payment",
    "model": "…",
    "os": "…",
    "brand": "…",
    "localVersion": "…"
  }
}

Response 200:

{
  "success": true,
  "message": "",
  "data": {
    "user": {
      "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "name": "Thandi",
      "surname": "Mokoena",
      "phone": "0821234567",
      "expoPushToken": "<token>",
      "idNumber": "9001015009087",
      "email": "thandi@example.com",
      "dob": "…",
      "roleId": 1,
      "is_verified": true,
      "needs_Review": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "isActive": true
    },
    "accessToken": "<token>",
    "refreshToken": "<token>",
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "businesses": [{
      "name": "Thandi",
      "busID": "…",
      "wallet": {
        "wallet_id": "9471452626",
        "balance": 150.00,
        "availableBalance": 150.00,
        "dailySpent": 1.00,
        "currency": "ZAR",
        "status": "Completed",
        "transactions": [ { … } ],
        "vouchers": [ { … } ]
      },
      "isVerified": true,
      "streetAddress": "…",
      "suburb": "Braamfontein",
      "city": "Johannesburg",
      "province": "Gauteng",
      "postalCode": "2001",
      "mcc": "…",
      "type": "Payment",
      "email": "thandi@example.com"
    }],
    "transactions": [{
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    }],
    "additionalData": { },
    "notificationPreference": {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "globalOptIn": true,
      "smsEnabled": true,
      "pushEnabled": true,
      "emailEnabled": true,
      "inAppEnabled": true,
      "systemEnabled": true,
      "marketingEnabled": true,
      "promotionsEnabled": true,
      "remindersEnabled": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z"
    }
  }
}

POST /api/auth/register/business

What it does: Same as register, plus a Business row and a separate business wallet.

See: §12.1 · §6.4

Auth: None; rate limit auth

Request (RegisterBusinessDTO):

{
  "name": "Thandi",
  "surname": "Mokoena",
  "email": "thandi@example.com",
  "phone_number": "0821234567",
  "password": "••••••",
  "idNumber": "9001015009087",
  "businessName": "Thandi",
  "businessRegNumber": "…",
  "businessType": "Payment",
  "idType": "Payment",
  "businessCategory": "…",
  "dob": "…",
  "latitude": 1.00,
  "longitude": 1.00,
  "uid": "…",
  "otp": "482913",
  "promoCode": "<code>",
  "deviceInfo": {
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "deviceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "publicKey": "…",
    "otp": "482913",
    "deviceType": "Payment",
    "model": "…",
    "os": "…",
    "brand": "…",
    "localVersion": "…"
  }
}

Response 200:

{
  "success": true,
  "message": "",
  "data": {
    "user": {
      "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "name": "Thandi",
      "surname": "Mokoena",
      "phone": "0821234567",
      "expoPushToken": "<token>",
      "idNumber": "9001015009087",
      "email": "thandi@example.com",
      "dob": "…",
      "roleId": 1,
      "is_verified": true,
      "needs_Review": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "isActive": true
    },
    "accessToken": "<token>",
    "refreshToken": "<token>",
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "businesses": [{
      "name": "Thandi",
      "busID": "…",
      "wallet": {
        "wallet_id": "9471452626",
        "balance": 150.00,
        "availableBalance": 150.00,
        "dailySpent": 1.00,
        "currency": "ZAR",
        "status": "Completed",
        "transactions": [ { … } ],
        "vouchers": [ { … } ]
      },
      "isVerified": true,
      "streetAddress": "…",
      "suburb": "Braamfontein",
      "city": "Johannesburg",
      "province": "Gauteng",
      "postalCode": "2001",
      "mcc": "…",
      "type": "Payment",
      "email": "thandi@example.com"
    }],
    "transactions": [{
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    }],
    "additionalData": { },
    "notificationPreference": {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "globalOptIn": true,
      "smsEnabled": true,
      "pushEnabled": true,
      "emailEnabled": true,
      "inAppEnabled": true,
      "systemEnabled": true,
      "marketingEnabled": true,
      "promotionsEnabled": true,
      "remindersEnabled": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z"
    }
  }
}

POST /api/auth/terminal/session

What it does: Signs a merchant's POS terminal into their business account using a session JWT from the POS service (serial number and merchant ID claims). Unknown merchants are fetched from Terminal:BootstrapUrl and created (13.21).

See: §12.1 · §13.21

Auth: None

Request (SessionDto):

{
  "sessionToken": "<token>",
  "sn": "…",
  "device": {
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "deviceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "publicKey": "…",
    "otp": "482913",
    "deviceType": "Payment",
    "model": "…",
    "os": "…",
    "brand": "…",
    "localVersion": "…"
  }
}

Response 200:

{
  "success": true,
  "message": "success",
  "data": {
    "user": {
      "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "name": "Thandi",
      "surname": "Mokoena",
      "phone": "0821234567",
      "expoPushToken": "<token>",
      "idNumber": "9001015009087",
      "email": "thandi@example.com",
      "dob": "…",
      "roleId": 1,
      "is_verified": true,
      "needs_Review": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "isActive": true
    },
    "accessToken": "<token>",
    "refreshToken": "<token>",
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "businesses": [{
      "name": "Thandi",
      "busID": "…",
      "wallet": {
        "wallet_id": "9471452626",
        "balance": 150.00,
        "availableBalance": 150.00,
        "dailySpent": 1.00,
        "currency": "ZAR",
        "status": "Completed",
        "transactions": [ { … } ],
        "vouchers": [ { … } ]
      },
      "isVerified": true,
      "streetAddress": "…",
      "suburb": "Braamfontein",
      "city": "Johannesburg",
      "province": "Gauteng",
      "postalCode": "2001",
      "mcc": "…",
      "type": "Payment",
      "email": "thandi@example.com"
    }],
    "transactions": [{
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    }],
    "additionalData": { },
    "notificationPreference": {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "globalOptIn": true,
      "smsEnabled": true,
      "pushEnabled": true,
      "emailEnabled": true,
      "inAppEnabled": true,
      "systemEnabled": true,
      "marketingEnabled": true,
      "promotionsEnabled": true,
      "remindersEnabled": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z"
    }
  }
}

20.2 Admin authentication, admins, partner clients and audit

POST /api/admin-auth/2fa/enable

What it does: Confirms an admin's first TOTP code with the 10-minute setup token, turns two-factor login on and returns one-time recovery codes (shown once, stored hashed).

See: §12.2 · §6.5

Auth: None; rate limit auth

Request (TwoFactorEnableDto):

{
  "email": "thandi@example.com",
  "setupToken": "<token>",
  "code": "<code>"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "recoveryCodes": "…"
  },
  "error": null
}

POST /api/admin-auth/2fa/setup

What it does: Starts two-factor enrolment for an admin who got 428 TwoFactorSetupRequired at login: creates the TOTP key (stored encrypted) and returns it for the authenticator app.

See: §12.2 · §6.5

Auth: None

Request (TwoFactorSetupRequestDto):

{
  "email": "thandi@example.com",
  "password": "••••••"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "sharedKey": "…",
    "authenticatorUri": "…",
    "setupToken": "<token>"
  },
  "error": null
}

POST /api/admin-auth/change-password

What it does: Changes the logged-in admin's password (Identity rules). Audited.

See: §12.2 · §6.5

Auth: Any JWT

Request (ChangeAdminPasswordDto):

{
  "currentPassword": "••••••",
  "newPassword": "••••••"
}

Response 200:

{
  "isSuccess": true,
  "data": "…",
  "error": null
}

POST /api/admin-auth/forgot-password

What it does: Emails the admin a password-reset link. Audited.

See: §12.2 · §6.5

Auth: None; rate limit auth

Request (ForgotPasswordDto):

{
  "email": "thandi@example.com"
}

Response 200:

{
  "isSuccess": true,
  "data": "…",
  "error": null
}

POST /api/admin-auth/login

What it does: Admin sign-in: password check with lockout (5 failures → 15 minutes, 423), mandatory TOTP or recovery code, then an access token in the body and a rotated refresh token in the refreshToken cookie.

See: §12.2 · §6.5

Auth: None; rate limit auth

Request (LoginAdminDto):

{
  "email": "thandi@example.com",
  "password": "••••••",
  "twoFactorCode": "<code>",
  "recoveryCode": "<code>"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "accessToken": "<token>",
    "refreshToken": "<token>",
    "passwordInitialChange": true,
    "twoFactorEnabled": true
  },
  "error": null
}

POST /api/admin-auth/logout

What it does: Ends the admin session (revokes the refresh token, clears the cookie).

See: §12.2 · §6.5

Auth: None

Response 204: no content.

POST /api/admin-auth/refresh

What it does: Exchanges the refreshToken cookie for a new access token, rotating the refresh token (the old one is revoked).

See: §12.2 · §6.5

Auth: None

Response 200:

{
  "isSuccess": true,
  "data": {
    "accessToken": "<token>",
    "refreshToken": "<token>",
    "passwordInitialChange": true,
    "twoFactorEnabled": true
  },
  "error": null
}

POST /api/admin-auth/register

What it does: Creates a new admin account (Identity user + Admins row) and emails them. Audited.

See: §12.2 · §6.5

Auth: Roles: Admin, SuperAdmin

Request (RegisterAdminDto):

{
  "email": "thandi@example.com",
  "contactNumber": "…",
  "firstName": "Thandi",
  "lastName": "Thandi",
  "role": "…",
  "password": "••••••",
  "department": "…"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": "…",
    "firstName": "Thandi",
    "lastName": "Thandi",
    "email": "thandi@example.com",
    "contactNumber": "…",
    "department": "…",
    "lastLoginUtc": "2026-11-11T08:30:00Z",
    "createdAtUtc": "2026-11-11T08:30:00Z",
    "updatedAtUtc": "2026-11-11T08:30:00Z",
    "passwordInitialChange": true,
    "isActive": true,
    "roles": "…"
  },
  "error": null
}

POST /api/admin-auth/reset-password

What it does: Sets a new admin password using the token from the reset email. Audited.

See: §12.2 · §6.5

Auth: None; rate limit auth

Request (ResetPasswordDto):

{
  "email": "thandi@example.com",
  "newPassword": "••••••",
  "token": "<token>"
}

Response 200:

{
  "isSuccess": true,
  "data": "…",
  "error": null
}

POST /api/admin-auth/unlock-account

What it does: Clears an admin's lockout. Audited.

See: §12.2 · §6.5

Auth: Roles: Admin, SuperAdmin

Request (UnlockAdminAccountDto):

{
  "email": "thandi@example.com"
}

Response 200:

{
  "isSuccess": true,
  "data": "…",
  "error": null
}

GET /api/admin/all

What it does: Lists admins with their roles (includeInactive to show deactivated ones).

See: §12.2

Auth: Roles: Admin, SuperAdmin

Query: includeInactive (bool)

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "id": "…",
      "firstName": "Thandi",
      "lastName": "Thandi",
      "email": "thandi@example.com",
      "contactNumber": "…",
      "department": "…",
      "isActive": true,
      "lastLoginUtc": "2026-11-11T08:30:00Z",
      "roles": "…"
    }
  ],
  "error": null
}

GET /api/admin/me/{id}

What it does: Returns an admin profile by Identity ID: your own, or anyone's for a SuperAdmin.

See: §12.2

Auth: Any JWT

Route: id (string)

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": "…",
    "firstName": "Thandi",
    "lastName": "Thandi",
    "email": "thandi@example.com",
    "contactNumber": "…",
    "department": "…"
  },
  "error": null
}

GET /api/admin/{id}

What it does: Returns one admin.

See: §12.2

Auth: Roles: Admin, SuperAdmin

Route: id (string)

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": "…",
    "firstName": "Thandi",
    "lastName": "Thandi",
    "email": "thandi@example.com",
    "contactNumber": "…",
    "department": "…",
    "lastLoginUtc": "2026-11-11T08:30:00Z",
    "createdAtUtc": "2026-11-11T08:30:00Z",
    "updatedAtUtc": "2026-11-11T08:30:00Z",
    "passwordInitialChange": true,
    "isActive": true,
    "roles": "…"
  },
  "error": null
}

DELETE /api/admin/{id}/delete

What it does: Deletes an admin account.

See: §12.2

Auth: Roles: SuperAdmin

Route: id (string)

Response 200:

{
  "isSuccess": true,
  "data": "…",
  "error": null
}

PUT /api/admin/{id}/role

What it does: Replaces an admin's role.

See: §12.2

Auth: Roles: SuperAdmin

Route: id (string)

Request (UpdateAdminRoleDto):

{
  "role": "…"
}

Response 200:

{
  "isSuccess": true,
  "data": "…",
  "error": null
}

PUT /api/admin/{id}/status

What it does: Activates or deactivates an admin.

See: §12.2

Auth: Roles: SuperAdmin

Route: id (string)

Request (UpdateAdminStatusDto):

{
  "isActive": true
}

Response 200:

{
  "isSuccess": true,
  "data": true,
  "error": null
}

PUT /api/admin/{id}/update

What it does: Updates an admin's profile (name, email/username): your own, or anyone's for a SuperAdmin.

See: §12.2

Auth: Any JWT

Route: id (string)

Request (UpdateAdminDto):

{
  "firstName": "Thandi",
  "lastName": "Thandi",
  "email": "thandi@example.com",
  "contactNumber": "…",
  "department": "…"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": "…",
    "firstName": "Thandi",
    "lastName": "Thandi",
    "email": "thandi@example.com",
    "contactNumber": "…",
    "department": "…",
    "lastLoginUtc": "2026-11-11T08:30:00Z",
    "createdAtUtc": "2026-11-11T08:30:00Z",
    "updatedAtUtc": "2026-11-11T08:30:00Z",
    "passwordInitialChange": true,
    "isActive": true,
    "roles": "…"
  },
  "error": null
}

POST /api/client-auth/token

What it does: Partner token endpoint (OAuth client credentials). Verifies the BCrypt-hashed secret, revokes the client's other active tokens (except Pay@), records the new token's jti and returns a JWT carrying the client's scope.

See: §12.11 · §6.6

Auth: None; rate limit auth

Body: application/x-www-form-urlencoded with client_id, client_secret, grant_type, scope.

Response 200:

{
  "isSuccess": true,
  "data": {
    "accessToken": "<token>",
    "tokenType": "Bearer",
    "expiresIn": 3600,
    "scope": "…"
  },
  "error": null
}

GET /api/admin/api-clients

What it does: Lists partner API clients.

See: §12.11 · §6.6

Auth: Roles: SuperAdmin

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "clientId": "…",
      "name": "Thandi",
      "scope": "…",
      "status": "Completed",
      "tokenLifetimeSeconds": 3600,
      "createdAt": "2026-11-11T08:30:00Z"
    }
  ],
  "error": null
}

POST /api/admin/api-clients

What it does: Registers a partner client (ID, secret, name, scope, token lifetime). The secret is stored as a BCrypt hash.

See: §12.11 · §6.6

Auth: Roles: SuperAdmin

Request (RegisterApiClientRequest):

{
  "clientId": "…",
  "clientSecret": "…",
  "name": "Thandi",
  "scope": "…",
  "tokenLifetimeSeconds": 3600
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "clientId": "…",
    "clientSecret": "…",
    "name": "Thandi",
    "scope": "…",
    "createdAt": "2026-11-11T08:30:00Z"
  },
  "error": null
}

PATCH /api/admin/api-clients/{clientId}/activate

What it does: Re-enables a suspended partner client.

See: §12.11 · §6.6

Auth: Roles: SuperAdmin

Route: clientId (string)

Response 200:

{
  "isSuccess": true,
  "data": "…",
  "error": null
}

PATCH /api/admin/api-clients/{clientId}/revoke-tokens

What it does: Revokes every active token of a partner client immediately.

See: §12.11 · §6.6

Auth: Roles: SuperAdmin

Route: clientId (string)

Response 200:

{
  "isSuccess": true,
  "data": "…",
  "error": null
}

PATCH /api/admin/api-clients/{clientId}/rotate-secret

What it does: Generates a new client secret (shown once).

See: §12.11 · §6.6

Auth: Roles: SuperAdmin

Route: clientId (string)

Response 200:

{
  "isSuccess": true,
  "data": {
    "clientId": "…",
    "newSecret": "…"
  },
  "error": null
}

PATCH /api/admin/api-clients/{clientId}/suspend

What it does: Suspends a partner client so it can't get tokens.

See: §12.11 · §6.6

Auth: Roles: SuperAdmin

Route: clientId (string)

Response 200:

{
  "isSuccess": true,
  "data": "…",
  "error": null
}

GET /api/audit

What it does: Lists the admin audit log (who did what, to which record).

See: §12.4

Auth: Roles: Admin, SuperAdmin

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "auditLogId": 1,
      "adminId": "…",
      "adminFirstName": "Thandi",
      "adminLastName": "Thandi",
      "adminEmail": "thandi@example.com",
      "action": "…",
      "description": "…",
      "targetType": "Payment",
      "targetId": "…",
      "timestamp": "2026-11-11T08:30:00Z"
    }
  ],
  "error": null
}

GET /api/audit/{id}

What it does: Audit entries of one admin.

See: §12.4

Auth: Roles: Admin, SuperAdmin

Route: id (string)

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "auditLogId": 1,
      "adminId": "…",
      "adminFirstName": "Thandi",
      "adminLastName": "Thandi",
      "adminEmail": "thandi@example.com",
      "action": "…",
      "description": "…",
      "targetType": "Payment",
      "targetId": "…",
      "timestamp": "2026-11-11T08:30:00Z"
    }
  ],
  "error": null
}

20.3 Users, KYC, documents, notes and addresses

POST /api/user/add/business

What it does: Adds a business and its business wallet to the logged-in user's account.

See: §12.3 · §13.22

Auth: Strict (user JWT + device signature)

Request (AddBusinessDTO):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "businessName": "Thandi",
  "businessRegNumber": "…",
  "businessType": "Payment",
  "businessEmail": "thandi@example.com",
  "businessCategory": "…"
}

Response 200:

{
  "success": true,
  "message": "",
  "data": {
    "user": {
      "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "name": "Thandi",
      "surname": "Mokoena",
      "phone": "0821234567",
      "expoPushToken": "<token>",
      "idNumber": "9001015009087",
      "email": "thandi@example.com",
      "dob": "…",
      "roleId": 1,
      "is_verified": true,
      "needs_Review": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "isActive": true
    },
    "accessToken": "<token>",
    "refreshToken": "<token>",
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "businesses": [{
      "name": "Thandi",
      "busID": "…",
      "wallet": {
        "wallet_id": "9471452626",
        "balance": 150.00,
        "availableBalance": 150.00,
        "dailySpent": 1.00,
        "currency": "ZAR",
        "status": "Completed",
        "transactions": [ { … } ],
        "vouchers": [ { … } ]
      },
      "isVerified": true,
      "streetAddress": "…",
      "suburb": "Braamfontein",
      "city": "Johannesburg",
      "province": "Gauteng",
      "postalCode": "2001",
      "mcc": "…",
      "type": "Payment",
      "email": "thandi@example.com"
    }],
    "transactions": [{
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    }],
    "additionalData": { },
    "notificationPreference": {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "globalOptIn": true,
      "smsEnabled": true,
      "pushEnabled": true,
      "emailEnabled": true,
      "inAppEnabled": true,
      "systemEnabled": true,
      "marketingEnabled": true,
      "promotionsEnabled": true,
      "remindersEnabled": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z"
    }
  }
}

GET /api/user/all

What it does: Dashboard user list, searchable and paged: wallet, contact details, KYC (is_verified) and status.

See: §12.3 · §13.22

Auth: Roles: SuperAdmin, Admin, SupportAdmin, Sales

Query: search (string?), page (int), pageSize (int)

Response 200:

{
  "isSuccess": true,
  "data": {
    "items": [
      {
        "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "walletId": "9471452626",
        "name": "Thandi",
        "phone": "0821234567",
        "email": "thandi@example.com",
        "balance": 150.00,
        "kyc": true,
        "status": true
      }
    ],
    "totalRecords": 1,
    "totalPages": 1,
    "currentPage": 1,
    "pageSize": 20,
    "hasNextPage": false,
    "hasPreviousPage": false
  },
  "error": null
}

POST /api/user/confirm

What it does: Confirms the user's PIN (used before account deletion).

See: §12.3 · §13.22

Auth: Strict (user JWT + device signature)

Request (DeleteDto):

{
  "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "password": "••••••"
}

Response 200:

{
  "success": true,
  "message": "",
  "data": true
}

DELETE /api/user/delete/{uid}

What it does: Soft-deletes the caller's own account (is_active=false, is_deleted=true) and emails a confirmation.

See: §12.3 · §13.22

Auth: Strict (user JWT + device signature)

Route: uid (Guid)

Response 200:

{
  "success": true,
  "message": "",
  "data": null
}

GET /api/user/nfc/tags/{walletId}

What it does: Staff view of a wallet's NFC tags (not pending ones).

See: §12.3 · §13.22

Auth: Roles: SuperAdmin, Admin, SupportAdmin, Sales

Route: walletId (string)

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "tagId": "tg_9f3k2m",
      "status": "Completed",
      "createdAt": "2026-11-11T08:30:00Z",
      "revokedAt": "2026-11-11T08:30:00Z"
    }
  ],
  "error": null
}

GET /api/user/unverified/Users

What it does: Users who haven't completed KYC, searchable (including ID number).

See: §12.3 · §13.22

Auth: Roles: SuperAdmin, Admin, SupportAdmin, Sales

Query: search (string?), page (int), pageSize (int)

Response 200:

{
  "isSuccess": true,
  "data": {
    "items": [
      {
        "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "idNumber": "9001015009087",
        "name": "Thandi",
        "phone": "0821234567",
        "email": "thandi@example.com",
        "kyc": true
      }
    ],
    "totalRecords": 1,
    "totalPages": 1,
    "currentPage": 1,
    "pageSize": 20,
    "hasNextPage": false,
    "hasPreviousPage": false
  },
  "error": null
}

PUT /api/user/update/email/{userId}

What it does: Changes the user's email after verifying the OTP.

See: §12.3 · §13.22

Auth: Strict (user JWT + device signature)

Route: userId (Guid)

Request (DetailsUpdateDto):

{
  "value": "true",
  "otp": "482913",
  "email": "thandi@example.com"
}

Response 200:

{
  "success": true,
  "message": "…",
  "data": { }
}

PUT /api/user/update/password/{userId}

What it does: Changes the user's PIN after verifying the OTP (stored with PBKDF2).

See: §12.3 · §13.22

Auth: Strict (user JWT + device signature)

Route: userId (Guid)

Request (DetailsUpdateDto):

{
  "value": "true",
  "otp": "482913",
  "email": "thandi@example.com"
}

Response 200:

{
  "success": true,
  "message": "…",
  "data": { }
}

PUT /api/user/update/phone/{userId}

What it does: Changes the user's phone number after verifying the OTP.

See: §12.3 · §13.22

Auth: Strict (user JWT + device signature)

Route: userId (Guid)

Request (DetailsUpdateDto):

{
  "value": "true",
  "otp": "482913",
  "email": "thandi@example.com"
}

Response 200:

{
  "success": true,
  "message": "…",
  "data": { }
}

POST /api/user/updateotp

What it does: Emails an OTP for a profile change (email, phone or PIN).

See: §12.3 · §13.22

Auth: Strict (user JWT + device signature); rate limit auth

Request (UserUpdateDto):

{
  "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "type": "Payment",
  "email": "thandi@example.com",
  "phone": "0821234567",
  "password": "••••••"
}

Response 200:

{
  "success": true,
  "message": "",
  "data": true
}

PUT /api/user/updatesignin/{userId}

What it does: Forgot-PIN flow: verifies the OTP and sets a new PIN.

See: §12.3 · §13.22

Auth: None; rate limit auth

Route: userId (Guid)

Request (UpdateRequestDto):

{
  "type": "Payment",
  "email": "thandi@example.com",
  "phone": "0821234567",
  "password": "••••••",
  "emailOTP": "…",
  "phoneOTP": "…"
}

Response 200:

{
  "success": true,
  "message": "…",
  "data": { }
}

POST /api/user/upload/{filepath}

What it does: Maintenance: bulk-creates verified users with wallets from ./test.csv and emails them passwords.

See: §12.3 · §13.22

Auth: Roles: SuperAdmin

Route: filePath (string)

Response 200:

[
  {
    "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "name": "Thandi",
    "surname": "Mokoena",
    "phone": "0821234567",
    "expoPushToken": "<token>",
    "idNumber": "9001015009087",
    "email": "thandi@example.com",
    "dob": "…",
    "roleId": 1,
    "is_verified": true,
    "needs_Review": true,
    "createdAt": "2026-11-11T08:30:00Z",
    "isActive": true
  }
]

GET /api/user/users

What it does: HTML dashboard of business KPIs: users, active users and daily/monthly volumes per rail.

See: §12.3 · §13.22

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Query: date (DateTime?)

Response 200: an HTML page (text/html).

GET /api/user/{userId}

What it does: Full user detail for staff: profile, wallets and business names.

See: §12.3 · §13.22

Auth: Roles: SuperAdmin, Admin, SupportAdmin, Sales

Route: userId (Guid)

Response 200:

{
  "isSuccess": true,
  "data": {
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "name": "Thandi",
    "phone": "0821234567",
    "email": "thandi@example.com",
    "kyc": true,
    "status": true,
    "mainAccount": {
      "accountType": "Payment",
      "walletId": "9471452626",
      "busId": "…",
      "businessName": "Thandi",
      "balance": 150.00,
      "currency": "ZAR",
      "status": "Completed"
    },
    "accounts": [{
      "accountType": "Payment",
      "walletId": "9471452626",
      "busId": "…",
      "businessName": "Thandi",
      "balance": 150.00,
      "currency": "ZAR",
      "status": "Completed"
    }]
  },
  "error": null
}

PUT /api/user/{userId}

What it does: Staff edit of a user's name, phone or email (checked for uniqueness).

See: §12.3 · §13.22

Auth: Roles: SuperAdmin, Admin, SupportAdmin, Sales

Route: userId (Guid)

Request (UpdateUserProfileDto):

{
  "name": "Thandi",
  "phone": "0821234567",
  "email": "thandi@example.com"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "name": "Thandi",
    "phone": "0821234567",
    "email": "thandi@example.com"
  },
  "error": null
}

GET /api/user/{userId}/accounts/{walletId}/transactions

What it does: Paged transactions of one of the user's wallets.

See: §12.3 · §13.22

Auth: Roles: SuperAdmin, Admin, SupportAdmin, Sales

Route: userId (Guid), walletId (string)

Query: page (int), pageSize (int)

Response 200:

{
  "isSuccess": true,
  "data": {
    "items": [
      {
        "id": 1,
        "title": "Send Zakhaa",
        "wallet_id": "9471452626",
        "type": "Payment",
        "fee": 2.30,
        "vat": 0.35,
        "sender_id": "…",
        "receiver_id": "8312045577",
        "amount": 150.00,
        "tipAmount": 0.00,
        "failureReason": null,
        "method": "Send Zakhaa",
        "reference": "Rent share",
        "transaction_id": "…",
        "status": "Completed",
        "create_at": "2026-11-11T08:30:00Z"
      }
    ],
    "totalRecords": 1,
    "totalPages": 1,
    "currentPage": 1,
    "pageSize": 20,
    "hasNextPage": false,
    "hasPreviousPage": false
  },
  "error": null
}

GET /api/user/{userId}/analytics

What it does: Totals from the ledger for the user's wallets (spent, received, average monthly spend).

See: §12.3 · §13.22

Auth: Roles: SuperAdmin, Admin, SupportAdmin, Sales

Route: userId (Guid)

Response 200:

{
  "isSuccess": true,
  "data": {
    "totalSpent": 1.00,
    "totalReceived": 1.00,
    "averageMonthlySpend": 1.00,
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "walletCount": 1,
    "totalBalance": 150.00
  },
  "error": null
}

PUT /api/user/{userId}/reactivate

What it does: Reactivates a suspended or frozen user (is_active=true). Audited.

See: §12.3 · §13.22

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Route: userId (Guid)

Response 200:

{
  "isSuccess": true,
  "data": { "userId": …, "status": … },
  "error": null
}

PUT /api/user/{userId}/suspend

What it does: Suspends a user (is_active=false); they can no longer log in. Audited.

See: §12.3 · §13.22

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Route: userId (Guid)

Response 200:

{
  "isSuccess": true,
  "data": {
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "status": false
  },
  "error": null
}

GET /api/kyc/getbyid/{id}

What it does: Returns one of the caller's legacy KYC profiles.

See: §12.3 · §13.18

Auth: Strict (user JWT + device signature)

Route: id (int)

Response 200:

{
  "profile_id": 1,
  "idNumber": "9001015009087",
  "id_type": "Payment",
  "id_doc": "…",
  "selfie": "…",
  "kyc_status": "Completed",
  "selfietype": "Payment",
  "iddoctype": "Payment",
  "verified_at": "2026-11-11T08:30:00Z",
  "created_at": "2026-11-11T08:30:00Z"
}

GET /api/kyc/getbyuid{uid}

What it does: Returns the caller's legacy KYC profile.

See: §12.3 · §13.18

Auth: Strict (user JWT + device signature)

Route: uid (Guid)

Response 200:

{
  "profile_id": 1,
  "idNumber": "9001015009087",
  "id_type": "Payment",
  "id_doc": "…",
  "selfie": "…",
  "kyc_status": "Completed",
  "selfietype": "Payment",
  "iddoctype": "Payment",
  "verified_at": "2026-11-11T08:30:00Z",
  "created_at": "2026-11-11T08:30:00Z"
}

POST /api/kyc/kyc

What it does: Creates a legacy KYC profile for the caller (ID number, ID document and selfie as base64). Always starts pending.

See: §12.3 · §13.18

Auth: Strict (user JWT + device signature)

Request (CreateKYCDto):

{
  "user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "idNumber": "9001015009087",
  "id_type": "Payment",
  "id_doc": "…",
  "selfie": "…",
  "kyc_status": "Completed",
  "verified_at": "2026-11-11T08:30:00Z",
  "created_at": "2026-11-11T08:30:00Z"
}

Response 200:

{
  "profile_id": 1,
  "idNumber": "9001015009087",
  "id_type": "Payment",
  "id_doc": "…",
  "selfie": "…",
  "kyc_status": "Completed",
  "selfietype": "Payment",
  "iddoctype": "Payment",
  "verified_at": "2026-11-11T08:30:00Z",
  "created_at": "2026-11-11T08:30:00Z"
}

DELETE /api/kyc/{id}

What it does: Deletes one of the caller's legacy KYC profiles.

See: §12.3 · §13.18

Auth: Strict (user JWT + device signature)

Route: id (int)

Response 200:

{
  "profile_id": 1,
  "user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "idNumber": "9001015009087",
  "id_type": "Payment",
  "id_doc": "<base64>",
  "selfie": "<base64>",
  "kyc_status": "Completed",
  "verified_at": "2026-11-11T08:30:00Z",
  "created_at": "2026-11-11T08:30:00Z",
  "user": {
    "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "name": "Thandi",
    "surname": "Mokoena",
    "email": "thandi@example.com",
    "idType": "Payment",
    "idNumber": "9001015009087",
    "expoPushToken": "<token>",
    "phone": "0821234567",
    "password": "••••••",
    "roleId": 1,
    "salt": "…",
    "is_verified": true,
    "is_active": true,
    "need_review": true,
    "is_deleted": true,
    "created_at": "2026-11-11T08:30:00Z",
    "updated_at": "2026-11-11T08:30:00Z",
    "deleted_At": "2026-11-11T08:30:00Z",
    "last_Login": "2026-11-11T08:30:00Z",
    "location": "…",
    "postal_code": "<code>",
    "dob": "…",
    "emailStatus": true,
    "phoneStatus": true,
    "addresses": [ { … } ],
    "kyC_Profile": {
      "profile_id": 1,
      "user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "idNumber": "9001015009087",
      "id_type": "Payment",
      "id_doc": "<base64>",
      "selfie": "<base64>",
      "kyc_status": "Completed",
      "verified_at": "2026-11-11T08:30:00Z",
      "created_at": "2026-11-11T08:30:00Z",
      "user": { … }
    },
    "amL_Flags": [ { … } ],
    "wallets": [ { … } ],
    "cards": [ { … } ],
    "kins": [ { … } ],
    "settings": {
      "settingId": 1,
      "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "notifyOn": true,
      "darkMode": true,
      "notifyOnTransaction": true,
      "notifyOnKycStatus": true,
      "enableTwoFactorAuth": true,
      "preferredLanguage": "…",
      "updatedAt": "2026-11-11T08:30:00Z",
      "user": { … }
    },
    "devices": [ { … } ],
    "notifications": [ { … } ],
    "notificationPreference": {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "user": { … },
      "globalOptIn": true,
      "smsEnabled": true,
      "pushEnabled": true,
      "emailEnabled": true,
      "inAppEnabled": true,
      "systemEnabled": true,
      "marketingEnabled": true,
      "promotionsEnabled": true,
      "remindersEnabled": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z"
    },
    "documents": [ { … } ],
    "businesses": [ { … } ],
    "zakhaaVouchers": [ { … } ],
    "userActivities": [ { … } ],
    "selectedUsers": [ { … } ],
    "recipients": [ { … } ],
    "displayAdEvents": [ { … } ],
    "userNotes": [ { … } ]
  }
}

PATCH /api/kyc/{id}

What it does: Updates the caller's legacy KYC profile (ID number, images). The status can't be changed by the user.

See: §12.3 · §13.18

Auth: Strict (user JWT + device signature)

Route: id (int)

Request (PutKYCDto):

{
  "idNumber": "9001015009087",
  "id_doc": "…",
  "selfie": "…",
  "kyc_status": "Completed",
  "verified_at": "2026-11-11T08:30:00Z"
}

Response 200:

{
  "profile_id": 1,
  "idNumber": "9001015009087",
  "id_type": "Payment",
  "id_doc": "…",
  "selfie": "…",
  "kyc_status": "Completed",
  "selfietype": "Payment",
  "iddoctype": "Payment",
  "verified_at": "2026-11-11T08:30:00Z",
  "created_at": "2026-11-11T08:30:00Z"
}

GET /api/documents/{userId}

What it does: Lists a user's KYC documents (metadata; files are in S3).

See: §12.3 · §13.18

Auth: Any JWT

Route: userId (Guid)

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "documentId": 1,
      "originalFileName": "Thandi",
      "documentType": "Payment",
      "status": "Completed",
      "uploadedAt": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z",
      "downloadUrl": "…",
      "previewUrl": "…"
    }
  ],
  "error": null
}

POST /api/documents/{userId}

What it does: Uploads a KYC document: max 5 MB, allow-listed types, virus-scanned by ClamAV, stored in S3.

See: §12.3 · §13.18

Auth: Any JWT

Route: userId (Guid)

Query: fileName (string?), documentType (string?)

Body: multipart/form-data with a file field.

Response 200:

{
  "isSuccess": true,
  "data": {
    "documentId": 1,
    "originalFileName": "Thandi",
    "s3Key": "…",
    "documentType": "Payment",
    "status": "Completed",
    "downloadUrl": "…"
  },
  "error": null
}

DELETE /api/documents/{userId}/{documentId}

What it does: Deletes a KYC document (S3 object and metadata).

See: §12.3 · §13.18

Auth: Any JWT

Route: userId (Guid), documentId (long)

Response 200:

{
  "isSuccess": true,
  "data": {
    "documentId": 1,
    "s3Key": "…",
    "status": "Completed"
  },
  "error": null
}

POST /api/documents/{userId}/approve

What it does: Approves a user's KYC documents (all must be Uploaded) and sets is_verified=true. Audited.

See: §12.3 · §13.18

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Route: userId (Guid)

Response 200:

{
  "isSuccess": true,
  "data": {
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "isVerified": true,
    "approvedDocuments": 1,
    "approvedAtUtc": "2026-11-11T08:30:00Z"
  },
  "error": null
}

POST /api/documents/{userId}/reject

What it does: Rejects one document with a reason, sets is_verified=false and emails the user. Audited.

See: §12.3 · §13.18

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Route: userId (Guid)

Request (RejectDocumentRequest):

{
  "documentId": 1,
  "userEmail": "thandi@example.com",
  "reason": "Customer request"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "documentId": 1,
    "userEmail": "thandi@example.com",
    "reason": "Customer request",
    "emailSent": true
  },
  "error": null
}

POST /api/documents/{userId}/unverify

What it does: Removes a user's verified status. Audited.

See: §12.3 · §13.18

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Route: userId (Guid)

Response 200:

{
  "isSuccess": true,
  "data": {
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "wasVerified": true,
    "isVerified": true,
    "needReview": true,
    "unverifiedAtUtc": "2026-11-11T08:30:00Z"
  },
  "error": null
}

GET /api/notes/user/{userId}

What it does: Internal staff notes on a user.

See: §12.3

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Route: userId (Guid)

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "adminId": "…",
      "actionType": "Payment",
      "status": "Completed",
      "reason": "Customer request",
      "createdAtUtc": "2026-11-11T08:30:00Z",
      "updatedAtUtc": "2026-11-11T08:30:00Z"
    }
  ],
  "error": null
}

POST /api/notes/user/{userId}

What it does: Adds an internal note (with a status) to a user. Audited.

See: §12.3

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Route: userId (Guid)

Request (CreateUserNoteDto):

{
  "actionType": "Payment",
  "status": "Completed",
  "reason": "Customer request"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "adminId": "…",
    "actionType": "Payment",
    "status": "Completed",
    "reason": "Customer request",
    "createdAtUtc": "2026-11-11T08:30:00Z",
    "updatedAtUtc": "2026-11-11T08:30:00Z"
  },
  "error": null
}

PUT /api/notes/{noteId}

What it does: Edits an internal note. Audited.

See: §12.3

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Route: noteId (Guid)

Request (UpdateUserNoteDto):

{
  "actionType": "Payment",
  "status": "Completed",
  "reason": "Customer request"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "adminId": "…",
    "actionType": "Payment",
    "status": "Completed",
    "reason": "Customer request",
    "createdAtUtc": "2026-11-11T08:30:00Z",
    "updatedAtUtc": "2026-11-11T08:30:00Z"
  },
  "error": null
}

POST /api/address

What it does: Adds an address to the caller's profile.

See: §12.3 · §13.22

Auth: Strict (user JWT + device signature)

Request (CreateAddressDto):

{
  "user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "street": "12 Main Rd",
  "suburb": "Braamfontein",
  "city": "Johannesburg",
  "province": "Gauteng",
  "postalCode": "2001",
  "country": "South Africa",
  "type": "Payment",
  "isVerified": true
}

Response 200:

{
  "address_id": 1,
  "user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "street": "12 Main Rd",
  "suburb": "Braamfontein",
  "city": "Johannesburg",
  "province": "Gauteng",
  "postalCode": "2001",
  "country": "South Africa",
  "type": "Payment",
  "isVerified": true,
  "user": {
    "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "name": "Thandi",
    "surname": "Mokoena",
    "email": "thandi@example.com",
    "idType": "Payment",
    "idNumber": "9001015009087",
    "expoPushToken": "<token>",
    "phone": "0821234567",
    "password": "••••••",
    "roleId": 1,
    "salt": "…",
    "is_verified": true,
    "is_active": true,
    "need_review": true,
    "is_deleted": true,
    "created_at": "2026-11-11T08:30:00Z",
    "updated_at": "2026-11-11T08:30:00Z",
    "deleted_At": "2026-11-11T08:30:00Z",
    "last_Login": "2026-11-11T08:30:00Z",
    "location": "…",
    "postal_code": "<code>",
    "dob": "…",
    "emailStatus": true,
    "phoneStatus": true,
    "addresses": [ { … } ],
    "kyC_Profile": {
      "profile_id": 1,
      "user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "idNumber": "9001015009087",
      "id_type": "Payment",
      "id_doc": "<base64>",
      "selfie": "<base64>",
      "kyc_status": "Completed",
      "verified_at": "2026-11-11T08:30:00Z",
      "created_at": "2026-11-11T08:30:00Z",
      "user": { … }
    },
    "amL_Flags": [ { … } ],
    "wallets": [ { … } ],
    "cards": [ { … } ],
    "kins": [ { … } ],
    "settings": {
      "settingId": 1,
      "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "notifyOn": true,
      "darkMode": true,
      "notifyOnTransaction": true,
      "notifyOnKycStatus": true,
      "enableTwoFactorAuth": true,
      "preferredLanguage": "…",
      "updatedAt": "2026-11-11T08:30:00Z",
      "user": { … }
    },
    "devices": [ { … } ],
    "notifications": [ { … } ],
    "notificationPreference": {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "user": { … },
      "globalOptIn": true,
      "smsEnabled": true,
      "pushEnabled": true,
      "emailEnabled": true,
      "inAppEnabled": true,
      "systemEnabled": true,
      "marketingEnabled": true,
      "promotionsEnabled": true,
      "remindersEnabled": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z"
    },
    "documents": [ { … } ],
    "businesses": [ { … } ],
    "zakhaaVouchers": [ { … } ],
    "userActivities": [ { … } ],
    "selectedUsers": [ { … } ],
    "recipients": [ { … } ],
    "displayAdEvents": [ { … } ],
    "userNotes": [ { … } ]
  }
}

GET /api/address/getall/{uid}

What it does: The caller's addresses.

See: §12.3 · §13.22

Auth: Strict (user JWT + device signature)

Route: uid (Guid)

Response 200:

[
  {
    "address_id": 1,
    "street": "12 Main Rd",
    "suburb": "Braamfontein",
    "city": "Johannesburg",
    "province": "Gauteng",
    "postalCode": "2001",
    "country": "South Africa",
    "type": "Payment",
    "isVerified": true
  }
]

GET /api/address/getbyid/{id}

What it does: One of the caller's addresses.

See: §12.3 · §13.22

Auth: Strict (user JWT + device signature)

Route: id (int)

Response 200:

{
  "address_id": 1,
  "street": "12 Main Rd",
  "suburb": "Braamfontein",
  "city": "Johannesburg",
  "province": "Gauteng",
  "postalCode": "2001",
  "country": "South Africa",
  "type": "Payment",
  "isVerified": true
}

DELETE /api/address/{id}

What it does: Deletes one of the caller's addresses.

See: §12.3 · §13.22

Auth: Strict (user JWT + device signature)

Route: id (int)

Response 200:

12

PUT /api/address/{id}

What it does: Updates one of the caller's addresses.

See: §12.3 · §13.22

Auth: Strict (user JWT + device signature)

Route: id (int)

Request (PutAddressDto):

{
  "street": "12 Main Rd",
  "suburb": "Braamfontein",
  "city": "Johannesburg",
  "province": "Gauteng",
  "postalCode": "2001",
  "country": "South Africa",
  "type": "Payment"
}

Response 200:

{
  "address_id": 1,
  "street": "12 Main Rd",
  "suburb": "Braamfontein",
  "city": "Johannesburg",
  "province": "Gauteng",
  "postalCode": "2001",
  "country": "South Africa",
  "type": "Payment",
  "isVerified": true
}

20.4 Wallets, top-ups, transactions, dashboard and helpdesk

POST /api/wallet

What it does: Manually creates a wallet (allocates the next free Zakhaa ID). Wallets are normally created at registration.

See: §12.4 · §13.22

Auth: Roles: SuperAdmin

Request (CreateWalletDto):

{
  "balance": 150.00,
  "user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "currency": "ZAR",
  "status": "Completed",
  "updated_at": "2026-11-11T08:30:00Z",
  "created_at": "2026-11-11T08:30:00Z"
}

Response 200:

{
  "wallet_id": "9471452626",
  "balance": 150.00,
  "availableBalance": 150.00,
  "dailySpent": 1.00,
  "currency": "ZAR",
  "status": "Completed",
  "transactions": [{
    "id": 1,
    "title": "Send Zakhaa",
    "wallet_id": "9471452626",
    "type": "Payment",
    "fee": 2.30,
    "vat": 0.35,
    "sender_id": "…",
    "receiver_id": "8312045577",
    "amount": 150.00,
    "tipAmount": 0.00,
    "failureReason": null,
    "method": "Send Zakhaa",
    "reference": "Rent share",
    "transaction_id": "…",
    "status": "Completed",
    "create_at": "2026-11-11T08:30:00Z"
  }],
  "vouchers": [{
    "id": 1,
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "voucher": "483920175562",
    "pin": "1234",
    "walletId": "9471452626",
    "amount": 150.00,
    "fee": 2.30,
    "vat": 0.35,
    "redeemed": true,
    "reversed": true,
    "expired": true,
    "createdAt": "2026-11-11T08:30:00Z",
    "expiresAt": "2026-11-11T08:30:00Z",
    "reversedAt": "2026-11-11T08:30:00Z",
    "redeemedAt": "2026-11-11T08:30:00Z"
  }]
}

GET /api/wallet/analytics

What it does: Platform-wide totals from the ledger: total spent, total received, average monthly spend.

See: §12.4 · §13.22

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Response 200:

{
  "isSuccess": true,
  "data": {
    "totalSpent": 1.00,
    "totalReceived": 1.00,
    "averageMonthlySpend": 1.00
  },
  "error": null
}

POST /api/wallet/generate/{count}

What it does: Generates random 10-digit Zakhaa IDs into the file Zakhaa Pay ZakhaaIDs.txt.

See: §12.4 · §13.22

Auth: Roles: SuperAdmin

Route: count (int)

Response 200: empty body.

POST /api/wallet/import

What it does: Imports the generated Zakhaa IDs into the WalletIDs pool (skips existing ones).

See: §12.4 · §13.22

Auth: Roles: SuperAdmin

Response 200:

{ "totalLinesInFile": …, "inserted": …, "skippedExisting": … }

DELETE /api/wallet/{id}

What it does: Deletes a wallet.

See: §12.4 · §13.22

Auth: Roles: SuperAdmin

Route: id (string)

Response 204: no content.

GET /api/wallet/{uid}

What it does: The caller's personal wallet.

See: §12.4 · §13.22

Auth: Strict (user JWT + device signature)

Route: uid (Guid)

Response 200:

{
  "wallet_id": "9471452626",
  "balance": 150.00,
  "availableBalance": 150.00,
  "dailySpent": 1.00,
  "currency": "ZAR",
  "status": "Completed",
  "transactions": [{
    "id": 1,
    "title": "Send Zakhaa",
    "wallet_id": "9471452626",
    "type": "Payment",
    "fee": 2.30,
    "vat": 0.35,
    "sender_id": "…",
    "receiver_id": "8312045577",
    "amount": 150.00,
    "tipAmount": 0.00,
    "failureReason": null,
    "method": "Send Zakhaa",
    "reference": "Rent share",
    "transaction_id": "…",
    "status": "Completed",
    "create_at": "2026-11-11T08:30:00Z"
  }],
  "vouchers": [{
    "id": 1,
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "voucher": "483920175562",
    "pin": "1234",
    "walletId": "9471452626",
    "amount": 150.00,
    "fee": 2.30,
    "vat": 0.35,
    "redeemed": true,
    "reversed": true,
    "expired": true,
    "createdAt": "2026-11-11T08:30:00Z",
    "expiresAt": "2026-11-11T08:30:00Z",
    "reversedAt": "2026-11-11T08:30:00Z",
    "redeemedAt": "2026-11-11T08:30:00Z"
  }]
}

GET /api/wallet/{walletId}

What it does: One of the caller's wallets: balance, available (spendable) balance, daily spend, status.

See: §12.4 · §13.22

Auth: Strict (user JWT + device signature)

Route: walletId (string)

Response 200:

{
  "wallet_id": "9471452626",
  "balance": 150.00,
  "availableBalance": 150.00,
  "dailySpent": 1.00,
  "currency": "ZAR",
  "status": "Completed",
  "transactions": [{
    "id": 1,
    "title": "Send Zakhaa",
    "wallet_id": "9471452626",
    "type": "Payment",
    "fee": 2.30,
    "vat": 0.35,
    "sender_id": "…",
    "receiver_id": "8312045577",
    "amount": 150.00,
    "tipAmount": 0.00,
    "failureReason": null,
    "method": "Send Zakhaa",
    "reference": "Rent share",
    "transaction_id": "…",
    "status": "Completed",
    "create_at": "2026-11-11T08:30:00Z"
  }],
  "vouchers": [{
    "id": 1,
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "voucher": "483920175562",
    "pin": "1234",
    "walletId": "9471452626",
    "amount": 150.00,
    "fee": 2.30,
    "vat": 0.35,
    "redeemed": true,
    "reversed": true,
    "expired": true,
    "createdAt": "2026-11-11T08:30:00Z",
    "expiresAt": "2026-11-11T08:30:00Z",
    "reversedAt": "2026-11-11T08:30:00Z",
    "redeemedAt": "2026-11-11T08:30:00Z"
  }]
}

GET /api/wallet/{walletId}/pay-at-barcode

What it does: Code 128 barcode (13010 + wallet ID) the customer shows at a Pay@ till to deposit cash.

See: §12.4 · §13.22

Auth: Strict (user JWT + device signature)

Route: walletId (string)

Response 200:

{
  "reference": "Rent share",
  "imageBase64": "…"
}

GET /api/topup/getbywallet/{walletid}

What it does: Completed credits (money in) of the caller's wallet.

See: §12.4 · §13.11

Auth: Strict (user JWT + device signature)

Route: walletid (string)

Response 200:

[
  {
    "topup_id": 1,
    "title": "Send Zakhaa",
    "sender_id": "…",
    "wallet_id": "9471452626",
    "amount": 150.00,
    "method": "Send Zakhaa",
    "reference": "Rent share",
    "status": "Completed",
    "create_at": "2026-11-11T08:30:00Z"
  }
]

POST /api/topup/top-up

What it does: Manual credit by a SuperAdmin: writes a Completed top-up, the ledger row and notifies the user. Audited.

See: §12.4 · §13.11

Auth: Roles: SuperAdmin

Request (CreateTopUpDto):

{
  "wallet_id": "9471452626",
  "sender_id": "…",
  "title": "Send Zakhaa",
  "amount": 150.00,
  "method": "Send Zakhaa",
  "reference": "Rent share",
  "status": "Completed",
  "create_at": "2026-11-11T08:30:00Z"
}

Response 200:

{
  "topup_id": 1,
  "title": "Send Zakhaa",
  "sender_id": "…",
  "wallet_id": "9471452626",
  "amount": 150.00,
  "method": "Send Zakhaa",
  "reference": "Rent share",
  "status": "Completed",
  "create_at": "2026-11-11T08:30:00Z"
}

GET /api/topup/{id}

What it does: One of the caller's credits.

See: §12.4 · §13.11

Auth: Strict (user JWT + device signature)

Route: id (int)

Response 200:

{
  "topup_id": 1,
  "title": "Send Zakhaa",
  "sender_id": "…",
  "wallet_id": "9471452626",
  "amount": 150.00,
  "method": "Send Zakhaa",
  "reference": "Rent share",
  "status": "Completed",
  "create_at": "2026-11-11T08:30:00Z"
}

PUT /api/topup/{id}

What it does: Corrects a top-up's status (amount can't be changed). Audited.

See: §12.4 · §13.11

Auth: Roles: SuperAdmin

Route: id (int)

Request (PutTopupDto):

{
  "amount": 150.00,
  "status": "Completed"
}

Response 200:

{
  "topup_id": 1,
  "title": "Send Zakhaa",
  "sender_id": "…",
  "wallet_id": "9471452626",
  "amount": 150.00,
  "method": "Send Zakhaa",
  "reference": "Rent share",
  "status": "Completed",
  "create_at": "2026-11-11T08:30:00Z"
}

GET /api/transactions

What it does: Every customer's transactions across all rails, paged and searchable (staff).

See: §12.4

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Query: page (int), pageSize (int), search (string?)

Response 200:

{
  "isSuccess": true,
  "data": {
    "items": [
      {
        "id": 1,
        "rail": "…",
        "displayId": "…",
        "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "user": "…",
        "type": "Payment",
        "status": "Completed",
        "amount": 150.00,
        "fee": 2.30,
        "channel": "…",
        "date": "2026-11-11T08:30:00Z"
      }
    ],
    "totalRecords": 1,
    "totalPages": 1,
    "currentPage": 1,
    "pageSize": 20,
    "hasNextPage": false,
    "hasPreviousPage": false
  },
  "error": null
}

GET /api/transactions/user/{userId}

What it does: The caller's transactions across all rails (up to take).

See: §12.4

Auth: Strict (user JWT + device signature)

Route: userId (Guid)

Query: take (int)

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "id": 1,
      "rail": "…",
      "displayId": "…",
      "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "user": "…",
      "type": "Payment",
      "status": "Completed",
      "amount": 150.00,
      "fee": 2.30,
      "channel": "…",
      "date": "2026-11-11T08:30:00Z"
    }
  ],
  "error": null
}

GET /api/transactions/{rail}/{id}

What it does: Detail of one transaction on a given rail (staff).

See: §12.4

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Route: rail (string), id (int)

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": 1,
    "rail": "…",
    "displayId": "…",
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "userName": "Thandi",
    "userPhone": "0821234567",
    "walletId": "9471452626",
    "type": "Payment",
    "status": "Completed",
    "amount": 150.00,
    "fee": 2.30,
    "vat": 0.35,
    "channel": "…",
    "reference": "Rent share",
    "senderId": "…",
    "receiverId": "…",
    "date": "2026-11-11T08:30:00Z",
    "cashSend": {
      "mobileNumber": "0821234567",
      "beneficiaryName": "Sipho Dlamini",
      "note": "Lunch",
      "cashSendId": "…",
      "uetr": "…",
      "failureReason": null,
      "refunded": true,
      "updatedAt": "2026-11-11T08:30:00Z"
    }
  },
  "error": null
}

GET /api/dashboard/notifications

What it does: Admin dashboard notifications.

See: §12.4

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Response 200:

{
  "isSuccess": true,
  "data": {
    "totalCount": 1,
    "unreadCount": 1,
    "recent": [{
      "id": 1,
      "category": "…",
      "source": "…",
      "severity": "…",
      "title": "Send Zakhaa",
      "message": "…",
      "createdAt": "2026-11-11T08:30:00Z",
      "isRead": true,
      "linkTo": "…"
    }]
  },
  "error": null
}

PUT /api/dashboard/notifications/read-all

What it does: Marks all admin notifications as read.

See: §12.4

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Response 200: empty body.

PUT /api/dashboard/notifications/{id}/read

What it does: Marks one admin notification as read.

See: §12.4

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Route: id (int)

Response 200: empty body.

GET /api/dashboard/overview

What it does: Dashboard home figures for a date (users, volumes, …).

See: §12.4

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Query: date (DateTime?)

Response 200:

{
  "isSuccess": true,
  "data": {
    "selectedDate": "2026-11-11T08:30:00Z",
    "canViewFinancialMetrics": true,
    "metrics": [{
      "label": "…",
      "value": "true",
      "delta": "…",
      "deltaType": "Payment",
      "health": "…",
      "isFinancial": true
    }],
    "dailyOperations": [{
      "category": "…",
      "title": "Send Zakhaa",
      "description": "…",
      "status": "Completed",
      "health": "…",
      "to": "…"
    }],
    "notifications": {
      "totalCount": 1,
      "unreadCount": 1,
      "recent": [ { … } ]
    }
  },
  "error": null
}

GET /api/helpdesk/customers/{ref}

What it does: Helpdesk lookup of a customer by wallet ID, phone or email.

See: §12.4

Auth: Policy ApiClient:Helpdesk

Route: ref (string)

Response 200:

{
  "isSuccess": true,
  "data": {
    "walletId": "9471452626",
    "name": "Thandi",
    "surname": "Mokoena",
    "fullName": "Thandi",
    "email": "thandi@example.com",
    "phone": "0821234567",
    "status": "Completed",
    "isActive": true,
    "kycStatus": "Completed",
    "balance": 150.00,
    "accountType": "Payment",
    "createdAt": "2026-11-11T08:30:00Z"
  },
  "error": null
}

GET /api/helpdesk/customers/{ref}/logs

What it does: Error-log entries mentioning the customer, paged.

See: §12.4

Auth: Policy ApiClient:Helpdesk

Route: ref (string)

Query: page (int), pageSize (int)

Response 200:

{
  "isSuccess": true,
  "data": {
    "walletId": "9471452626",
    "totalCount": 1,
    "logs": [{
      "id": 1,
      "message": "…",
      "level": "…",
      "createdAt": "2026-11-11T08:30:00Z"
    }]
  },
  "error": null
}

GET /api/helpdesk/customers/{walletId}/summary

What it does: Account summary of a wallet for the helpdesk.

See: §12.4

Auth: Policy ApiClient:Helpdesk

Route: walletId (string)

Response 200:

{
  "isSuccess": true,
  "data": {
    "customer": {
      "walletId": "9471452626",
      "name": "Thandi",
      "surname": "Mokoena",
      "fullName": "Thandi",
      "email": "thandi@example.com",
      "phone": "0821234567",
      "status": "Completed",
      "isActive": true,
      "kycStatus": "Completed",
      "balance": 150.00,
      "accountType": "Payment",
      "createdAt": "2026-11-11T08:30:00Z"
    },
    "wallet": {
      "walletId": "9471452626",
      "status": "Completed",
      "balance": 150.00,
      "currency": "ZAR",
      "ownerName": "Thandi",
      "ownerEmail": "thandi@example.com",
      "ownerPhone": "0821234567",
      "isActive": true,
      "createdAt": "2026-11-11T08:30:00Z"
    },
    "totalSpent": 1.00,
    "totalReceived": 1.00,
    "transactionCount": 1,
    "lastTransaction": {
      "reference": "Rent share",
      "title": "Send Zakhaa",
      "type": "Payment",
      "channel": "…",
      "direction": "…",
      "amount": 150.00,
      "fee": 2.30,
      "vat": 0.35,
      "openingBalance": 150.00,
      "closingBalance": 150.00,
      "currency": "ZAR",
      "status": "Completed",
      "failureReason": null,
      "sourceWalletId": "9471452626",
      "destinationWalletId": "9471452626",
      "sourceTable": "…",
      "createdAt": "2026-11-11T08:30:00Z"
    }
  },
  "error": null
}

GET /api/helpdesk/customers/{walletId}/transactions

What it does: Transaction history of a wallet for the helpdesk, paged.

See: §12.4

Auth: Policy ApiClient:Helpdesk

Route: walletId (string)

Query: page (int), pageSize (int)

Response 200:

{
  "isSuccess": true,
  "data": {
    "walletId": "9471452626",
    "page": 1,
    "pageSize": 20,
    "totalCount": 1,
    "totalPages": 1,
    "transactions": [{
      "reference": "Rent share",
      "title": "Send Zakhaa",
      "type": "Payment",
      "channel": "…",
      "direction": "…",
      "amount": 150.00,
      "fee": 2.30,
      "vat": 0.35,
      "openingBalance": 150.00,
      "closingBalance": 150.00,
      "currency": "ZAR",
      "status": "Completed",
      "failureReason": null,
      "sourceWalletId": "9471452626",
      "destinationWalletId": "9471452626",
      "sourceTable": "…",
      "createdAt": "2026-11-11T08:30:00Z"
    }]
  },
  "error": null
}

GET /api/helpdesk/transactions/{ref}

What it does: One transaction by reference (optionally by source table and wallet).

See: §12.4

Auth: Policy ApiClient:Helpdesk

Route: ref (string)

Query: source_table (string?), wallet_id (string)

Response 200:

{
  "isSuccess": true,
  "data": {
    "reference": "Rent share",
    "title": "Send Zakhaa",
    "type": "Payment",
    "channel": "…",
    "direction": "…",
    "amount": 150.00,
    "fee": 2.30,
    "vat": 0.35,
    "openingBalance": 150.00,
    "closingBalance": 150.00,
    "currency": "ZAR",
    "status": "Completed",
    "failureReason": null,
    "sourceWalletId": "9471452626",
    "destinationWalletId": "9471452626",
    "sourceTable": "…",
    "createdAt": "2026-11-11T08:30:00Z"
  },
  "error": null
}

GET /api/helpdesk/wallets/{walletId}

What it does: Wallet details including the owner's contact details.

See: §12.4

Auth: Policy ApiClient:Helpdesk

Route: walletId (string)

Response 200:

{
  "isSuccess": true,
  "data": {
    "walletId": "9471452626",
    "status": "Completed",
    "balance": 150.00,
    "currency": "ZAR",
    "ownerName": "Thandi",
    "ownerEmail": "thandi@example.com",
    "ownerPhone": "0821234567",
    "isActive": true,
    "createdAt": "2026-11-11T08:30:00Z"
  },
  "error": null
}

20.5 Send Zakhaa and withdrawals

POST /api/withdrawal

What it does: Send Zakhaa. Debits the sender and credits the receiver in one serializable transaction (both wallets locked), with limits, trust-tier cap, deposit holds and the ZAKPEER/ZAKVOUCHER fee + VAT. OTP from R500. If the reference is an open checkout order, it pays that order instead (13.2). Notifies both users.

See: §12.5 · §13.8 · §13.2

Auth: Strict (user JWT + device signature)

Request (CreateWithdrawalDto):

{
  "title": "Send Zakhaa",
  "wallet_id": "9471452626",
  "amount": 150.00,
  "receiver_id": "8312045577",
  "method": "Send Zakhaa",
  "reference": "Rent share",
  "status": "Completed",
  "otp": "482913",
  "type": "Payment",
  "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "create_at": "2026-11-11T08:30:00Z"
}

Response 200:

{
  "wallet": {
    "wallet_id": "9471452626",
    "balance": 150.00,
    "availableBalance": 150.00,
    "dailySpent": 1.00,
    "currency": "ZAR",
    "status": "Completed",
    "transactions": [ { … } ],
    "vouchers": [ { … } ]
  },
  "transaction": {
    "id": 1,
    "title": "Send Zakhaa",
    "wallet_id": "9471452626",
    "type": "Payment",
    "fee": 2.30,
    "vat": 0.35,
    "sender_id": "…",
    "receiver_id": "8312045577",
    "amount": 150.00,
    "tipAmount": 0.00,
    "failureReason": null,
    "method": "Send Zakhaa",
    "reference": "Rent share",
    "transaction_id": "…",
    "status": "Completed",
    "create_at": "2026-11-11T08:30:00Z"
  },
  "beneficiary": {
    "beneficiary_id": 1,
    "name": "Thandi",
    "surname": "Mokoena",
    "receiver_id": "8312045577",
    "wallet_id": "9471452626",
    "createdAt": "2026-11-11T08:30:00Z"
  },
  "notification": {
    "notificationId": 1,
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "title": "Send Zakhaa",
    "message": "…",
    "type": "Payment",
    "isRead": true,
    "createdAt": "2026-11-11T08:30:00Z"
  },
  "reason": null
}

POST /api/withdrawal/BBM

What it does: Pushes a "payment received" amount to the BigBrotherShop real-time channel (legacy).

See: §12.5 · §13.8 · §13.2

Auth: Roles: SuperAdmin

Request body: a bare JSON decimal, e.g. 150.00.

Response 200: empty body.

GET /api/withdrawal/getBeneficiaries/{wallet_id}

What it does: People the caller has sent Zakhaa to before (saved automatically).

See: §12.5 · §13.8 · §13.2

Auth: Strict (user JWT + device signature)

Route: wallet_id (string)

Response 200:

{
  "success": true,
  "message": "",
  "data": [
    { … }
  ]
}

GET /api/withdrawal/getbyid/{id}

What it does: One of the caller's debits (money out).

See: §12.5 · §13.8 · §13.2

Auth: Strict (user JWT + device signature)

Route: id (int)

Response 200:

{
  "id": 1,
  "title": "Send Zakhaa",
  "wallet_id": "9471452626",
  "type": "Payment",
  "fee": 2.30,
  "vat": 0.35,
  "sender_id": "…",
  "receiver_id": "8312045577",
  "amount": 150.00,
  "tipAmount": 0.00,
  "failureReason": null,
  "method": "Send Zakhaa",
  "reference": "Rent share",
  "transaction_id": "…",
  "status": "Completed",
  "create_at": "2026-11-11T08:30:00Z"
}

GET /api/withdrawal/getbywalletid{walletid}

What it does: All debits of the caller's wallet.

See: §12.5 · §13.8 · §13.2

Auth: Strict (user JWT + device signature)

Route: walletid (string)

Response 200:

[
  {
    "id": 1,
    "title": "Send Zakhaa",
    "wallet_id": "9471452626",
    "type": "Payment",
    "fee": 2.30,
    "vat": 0.35,
    "sender_id": "…",
    "receiver_id": "8312045577",
    "amount": 150.00,
    "tipAmount": 0.00,
    "failureReason": null,
    "method": "Send Zakhaa",
    "reference": "Rent share",
    "transaction_id": "…",
    "status": "Completed",
    "create_at": "2026-11-11T08:30:00Z"
  }
]

POST /api/withdrawal/{filePath}

What it does: Maintenance: imports 10-digit Zakhaa IDs from a file in the app folder.

See: §12.5 · §13.8 · §13.2

Auth: Roles: SuperAdmin

Route: filePath (string)

Response 200:

[
  "9471452626",
  "8312045577"
]

20.6 PayShap

POST /api/payment/callback/register

What it does: Registers our callback with the PayShap middleware.

See: §12.6 · §13.14

Auth: Strict (user JWT + device signature)

Request (PaymentCallbackDto):

{
  "uri": "…",
  "supportEmail": "thandi@example.com",
  "token": "<token>"
}

Response 200:

{
  "success": true,
  "message": "",
  "data": {
    "isSuccess": true,
    "correlationId": "…",
    "session": "…",
    "errorList": [{
      "code": "<code>",
      "description": "…"
    }],
    "responseUtcTime": "…"
  }
}

POST /api/payment/callback/unregister

What it does: Removes our callback from the PayShap middleware.

See: §12.6 · §13.14

Auth: Strict (user JWT + device signature)

Request (PaymentCallbackDto):

{
  "uri": "…",
  "supportEmail": "thandi@example.com",
  "token": "<token>"
}

Response 200:

{
  "success": true,
  "message": "",
  "data": {
    "isSuccess": true,
    "correlationId": "…",
    "session": "…",
    "errorList": [{
      "code": "<code>",
      "description": "…"
    }],
    "responseUtcTime": "…"
  }
}

GET /api/payment/can-pay/{wallet_id}

What it does: PayShap eligibility: 2 payments a day, 30 minutes apart, and how much can be sent now.

See: §12.6 · §13.14

Auth: Strict (user JWT + device signature)

Route: wallet_id (string)

Response 200:

{
  "success": true,
  "message": "",
  "data": {
    "canMakePayment": true,
    "available": 1.00,
    "dailyUsed": 1,
    "dailyLimit": 1,
    "nextAllowedAt": "2026-11-11T08:30:00Z",
    "reason": null
  }
}

GET /api/payment/getBeneficiaries/{wallet_id}

What it does: Bank accounts the caller has paid by PayShap before.

See: §12.6 · §13.14

Auth: Strict (user JWT + device signature)

Route: wallet_id (string)

Response 200:

{
  "success": true,
  "message": "",
  "data": [
    { … }
  ]
}

POST /api/payment/initiate-payment

What it does: PayShap payment to a bank account. Verifies the OTP, reserves the funds (limits, fraud check, holds), sends the payment through the signed middleware, saves the beneficiary and notifies; refunds if the bank rejects it. Final status by polling (13.14).

See: §12.6 · §13.14

Auth: Strict (user JWT + device signature)

Request (PayshapPaymentDto):

{
  "acc": "62000000000",
  "walletID": "9471452626",
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "otp": "482913",
  "branch": "250655",
  "bankName": "FNB",
  "accHolderName": "T Mokoena",
  "amount": 150.00,
  "reference": "Rent share",
  "myReference": "Rent share",
  "accType": "Cheque",
  "sendPop": true,
  "transRef": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "popEmail": "thandi@example.com"
}

Response 200:

{
  "success": true,
  "message": "",
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "beneficiary": {
      "bankName": "FNB",
      "accHolderName": "T Mokoena",
      "accountNumber": "<account / meter / wallet number>",
      "branchCode": "<code>",
      "accountType": "Payment"
    },
    "notification": {
      "notificationId": 1,
      "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "title": "Send Zakhaa",
      "message": "…",
      "type": "Payment",
      "isRead": true,
      "createdAt": "2026-11-11T08:30:00Z"
    },
    "reason": null
  }
}

20.7 Scan to Pay, XPay, QR and NFC

POST /api/stp/callback

What it does: EFT Corp's encrypted payment notification. Decrypted, stored, acknowledged at once and processed in the background: verified with EFT Corp, then credits, reverses or confirms a refund (13.3).

See: §12.7 · §13.3

Auth: None

Response 200: empty body.

POST /api/stp/finalizestp

What it does: Pays a scanned Scan to Pay QR from the wallet: reserves amount + tip, finalises with EFT Corp, refunds if it doesn't succeed (13.15).

See: §12.7 · §13.15

Auth: Strict (user JWT + device signature)

Request (ScanToPayFinalizeDto):

{
  "externalUniqueId": "…",
  "phone": "0821234567",
  "paymentData": "…",
  "walletId": "9471452626",
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "currency": "ZAR",
  "reference": "Rent share",
  "amount": 150.00,
  "additionalFields": [{
    "id": "…",
    "value": "true"
  }]
}

Response 200:

{ "success": true, "data": { … } }

POST /api/stp/initiate/xpay

What it does: Web checkout: starts a card / Apple Pay / Google Pay / Samsung Pay payment into a Zakhaa wallet or a checkout order (server-held amount for orders). The credit arrives via the EFT Corp callback.

See: §12.7 · §13.1 · §13.11

Auth: None; rate limit stp-initiate

Request (ScanToPayFinalizeMiddlewareRequest):

{
  "externalUniqueId": "…",
  "currency": "ZAR",
  "amount": 150.00,
  "phone": "0821234567",
  "paymentData": "…",
  "additionalFields": [{
    "id": "…",
    "value": "true"
  }],
  "landingUrl": "…",
  "reference": "Rent share",
  "merchantName": "Thandi",
  "description": "…"
}

Response 200:

{
  "statusCode": 200,
  "result": {
    "isSuccess": true,
    "data": {
      "paymentId": "…",
      "externalUniqueId": "…",
      "status": "Completed",
      "amount": "…",
      "currency": "ZAR",
      "description": "…",
      "merchantName": "Thandi",
      "merchantReference": "Rent share",
      "stpFee": "…",
      "completionUrl": "…",
      "partialPaymentAllowed": true,
      "hasTip": true,
      "hasReference": true
    },
    "error": null
  }
}

POST /api/stp/initiatestp

What it does: The app scanned a Scan to Pay QR: fetches its details (amount, merchant, reference) from EFT Corp for the user to confirm.

See: §12.7 · §13.15

Auth: Strict (user JWT + device signature)

Request (ScanToPayInitiateRequest):

{
  "qrCodeNumber": "…",
  "customerPhone": "0821234567",
  "walletId": "9471452626"
}

Response 200:

{ "success": …, "data": … }

POST /api/stp/nfc/tags/generate

What it does: Creates a new NFC tag (opaque ID + URL to write to the sticker) for the caller's wallet, status Pending.

See: §12.7 · §13.16

Auth: Strict (user JWT + device signature)

Request (GenerateNfcTagRequest):

{
  "walletId": "9471452626"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "url": "https://example.com",
    "tagId": "tg_9f3k2m"
  },
  "error": null
}

GET /api/stp/nfc/tags/resolve/{tagId}

What it does: Public: resolves a tapped tag to the wallet to pay, only if the tag and wallet are active.

See: §12.7 · §13.16

Auth: None; rate limit stp-initiate

Route: tagId (string)

Response 200:

{
  "isSuccess": true,
  "data": { "walletId": …, "merchantName": … },
  "error": null
}

POST /api/stp/nfc/tags/{tagId}/abandon

What it does: Abandons a tag that was never activated.

See: §12.7 · §13.16

Auth: Strict (user JWT + device signature)

Route: tagId (string)

Request (NfcTagActionRequest):

{
  "walletId": "9471452626"
}

Response 200:

{
  "isSuccess": true,
  "data": { … },
  "error": null
}

POST /api/stp/nfc/tags/{tagId}/activate

What it does: Activates a written tag (Pending → Active).

See: §12.7 · §13.16

Auth: Strict (user JWT + device signature)

Route: tagId (string)

Request (NfcTagActionRequest):

{
  "walletId": "9471452626"
}

Response 200:

{
  "isSuccess": true,
  "data": { … },
  "error": null
}

POST /api/stp/nfc/tags/{tagId}/deactivate

What it does: Temporarily disables a tag (Active → Inactive).

See: §12.7 · §13.16

Auth: Strict (user JWT + device signature)

Route: tagId (string)

Request (NfcTagActionRequest):

{
  "walletId": "9471452626"
}

Response 200:

{
  "isSuccess": true,
  "data": { … },
  "error": null
}

POST /api/stp/nfc/tags/{tagId}/reactivate

What it does: Re-enables an inactive tag.

See: §12.7 · §13.16

Auth: Strict (user JWT + device signature)

Route: tagId (string)

Request (NfcTagActionRequest):

{
  "walletId": "9471452626"
}

Response 200:

{
  "isSuccess": true,
  "data": { … },
  "error": null
}

POST /api/stp/nfc/tags/{tagId}/revoke

What it does: Permanently revokes a tag.

See: §12.7 · §13.16

Auth: Strict (user JWT + device signature)

Route: tagId (string)

Request (NfcTagActionRequest):

{
  "walletId": "9471452626"
}

Response 200:

{
  "isSuccess": true,
  "data": { … },
  "error": null
}

GET /api/stp/nfc/tags/{walletId}

What it does: The caller's wallet's NFC tags.

See: §12.7 · §13.16

Auth: Strict (user JWT + device signature)

Route: walletId (string)

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "tagId": "tg_9f3k2m",
      "status": "Completed",
      "createdAt": "2026-11-11T08:30:00Z",
      "revokedAt": "2026-11-11T08:30:00Z"
    }
  ],
  "error": null
}

POST /api/stp/payment/callback

What it does: Legacy callback: acknowledges only.

See: §12.7 · §13.15

Auth: None

Response 200: empty body.

POST /api/stp/refund-webhook/{refundId}/{token}

What it does: EFT Corp's refund outcome for one refund. Checked against that refund's one-time token, then verified with EFT Corp before the refund is settled (13.5).

See: §12.7 · §13.5

Auth: None

Route: refundId (string), token (string)

Query: refunds (PaymentRefundService)

Response 200: empty body.

GET /api/stp/resolve/merchant/{zakhaaId}

What it does: Display name of the wallet/merchant behind a Zakhaa ID (for payment pages).

See: §12.7 · §13.15

Auth: None; rate limit stp-initiate

Route: zakhaaId (string)

Response 200:

{
  "isSuccess": true,
  "data": {
    "paymentId": "…",
    "externalUniqueId": "…",
    "status": "Completed",
    "amount": "…",
    "currency": "ZAR",
    "description": "…",
    "merchantName": "Thandi",
    "merchantReference": "Rent share",
    "stpFee": "…",
    "completionUrl": "…",
    "partialPaymentAllowed": true,
    "hasTip": true,
    "hasReference": true
  },
  "error": null
}

GET /api/stp/status/{paymentId}

What it does: Status of a Scan to Pay payment from the middleware.

See: §12.7 · §13.15

Auth: None

Route: paymentId (string)

Response 200:

{
  "paymentId": 1,
  "externalUniqueId": "…",
  "status": "Completed",
  "amount": 150.00,
  "currency": "ZAR",
  "description": "…",
  "merchantName": "Thandi",
  "merchantReference": "Rent share",
  "created": "…",
  "fee": 2.30,
  "partialPaymentAllowed": true,
  "hasTip": true,
  "hasReference": true,
  "completionUrl": "…"
}

GET /api/qr/lookup

What it does: EFT Corp scanned a Zakhaa QR: returns who to pay and the amount (server-held amount for checkout orders).

See: §12.7 · §13.15

Auth: Policy ApiClient:EftCorp; + token revocation check

Query: code (string)

Response 200:

{
  "isSuccess": true,
  "data": {
    "status": "Completed",
    "merchantRef": "…",
    "amount": 150.00,
    "amountType": "Payment",
    "currency": "ZAR",
    "merchantName": "Thandi"
  },
  "error": null
}

POST /api/qr/lookup

What it does: The app scanned a QR code (Zapper or Scan to Pay): looks up its payment details.

See: §12.7 · §13.15

Auth: Strict (user JWT + device signature)

Request (ScanToPayInitiateRequest):

{
  "qrCodeNumber": "…",
  "customerPhone": "0821234567",
  "walletId": "9471452626"
}

Response 200:

{ "success": …, "msg": … }

GET /api/qr/lookup/{walletId}/qr

What it does: The wallet's personal payment QR link.

See: §12.7 · §13.15

Auth: Any JWT

Route: walletId (string)

Response 200:

"https://pay.zakhaapay.com/qr?ZakhaaID=9471452626&…"

20.8 Zapper, CashSend, Zakhaa vouchers and Pay@

POST /api/zapper/finalize

What it does: Pays a Zapper QR from the wallet: reserves funds, tells Zapper the payment succeeded, finalises; refunds and tells Zapper on failure.

See: §12.8 · §13.15

Auth: Strict (user JWT + device signature)

Request (ZapperPaymentNotifyDTO):

{
  "walletId": "9471452626",
  "reference": "Rent share",
  "amount": 150.00,
  "code": "<code>",
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "otp": "482913"
}

Response 200:

{ "success": true, "data": { … } }

POST /api/zapper/initiate

What it does: Decodes a scanned Zapper QR (base64) into payment details.

See: §12.8 · §13.15

Auth: Strict (user JWT + device signature)

Request body: a bare JSON string.

Response 200:

{ "success": …, "msg": … }

GET /api/cashsend/all/{walletId}

What it does: The caller's cash sends.

See: §12.8 · §13.13

Auth: Strict (user JWT + device signature)

Route: walletId (string)

Response 200:

{
  "success": true,
  "message": "",
  "data": [
    {
      "id": 1,
      "amount": 150.00,
      "fee": 2.30,
      "vat": 0.35,
      "mobile_number": "0821234567",
      "beneficiary_name": "Sipho Dlamini",
      "note": "Lunch",
      "reference": "Rent share",
      "cashsend_id": "…",
      "withdrawal_number": "…",
      "status": "Completed",
      "failure_reason": null,
      "create_at": "2026-11-11T08:30:00Z",
      "updated_at": "2026-11-11T08:30:00Z"
    }
  ]
}

POST /api/cashsend/cancel

What it does: Asks TransMS to cancel an Active/Pending cash send; the refund happens when the cancellation webhook arrives.

See: §12.8 · §13.13

Auth: Strict (user JWT + device signature)

Request (CashSendCancelDto):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletID": "9471452626",
  "transactionId": 1,
  "reference": "Rent share",
  "otp": "482913"
}

Response 200:

{
  "success": true,
  "message": "",
  "data": {
    "transaction": {
      "id": 1,
      "amount": 150.00,
      "fee": 2.30,
      "vat": 0.35,
      "mobile_number": "0821234567",
      "beneficiary_name": "Sipho Dlamini",
      "note": "Lunch",
      "reference": "Rent share",
      "cashsend_id": "…",
      "withdrawal_number": "…",
      "status": "Completed",
      "failure_reason": null,
      "create_at": "2026-11-11T08:30:00Z",
      "updated_at": "2026-11-11T08:30:00Z"
    },
    "transDto": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "reason": null
  }
}

POST /api/cashsend/initiate

What it does: Sends cash to a phone number (collected at an ATM): reserves funds, sends the request to TransMS/ABSA with our callback URL and a one-time token, refunds if rejected (13.13).

See: §12.8 · §13.13

Auth: Strict (user JWT + device signature)

Request (CashSendInitiateDto):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletID": "9471452626",
  "amount": 150.00,
  "mobileNumber": "0821234567",
  "beneficiaryName": "Sipho Dlamini",
  "note": "Lunch",
  "reference": "Rent share",
  "otp": "482913"
}

Response 200:

{
  "success": true,
  "message": "",
  "data": {
    "transaction": {
      "id": 1,
      "amount": 150.00,
      "fee": 2.30,
      "vat": 0.35,
      "mobile_number": "0821234567",
      "beneficiary_name": "Sipho Dlamini",
      "note": "Lunch",
      "reference": "Rent share",
      "cashsend_id": "…",
      "withdrawal_number": "…",
      "status": "Completed",
      "failure_reason": null,
      "create_at": "2026-11-11T08:30:00Z",
      "updated_at": "2026-11-11T08:30:00Z"
    },
    "transDto": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "reason": null
  }
}

POST /api/cashsend/resend-withdrawal

What it does: Resends the withdrawal number to the recipient.

See: §12.8 · §13.13

Auth: Strict (user JWT + device signature)

Request (CashSendActionDto):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletID": "9471452626",
  "transactionId": 1,
  "reference": "Rent share"
}

Response 200:

{
  "success": true,
  "message": "",
  "data": {
    "transaction": {
      "id": 1,
      "amount": 150.00,
      "fee": 2.30,
      "vat": 0.35,
      "mobile_number": "0821234567",
      "beneficiary_name": "Sipho Dlamini",
      "note": "Lunch",
      "reference": "Rent share",
      "cashsend_id": "…",
      "withdrawal_number": "…",
      "status": "Completed",
      "failure_reason": null,
      "create_at": "2026-11-11T08:30:00Z",
      "updated_at": "2026-11-11T08:30:00Z"
    },
    "transDto": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "reason": null
  }
}

POST /api/cashsend/reset-pin

What it does: Resets the recipient's collection PIN.

See: §12.8 · §13.13

Auth: Strict (user JWT + device signature)

Request (CashSendActionDto):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletID": "9471452626",
  "transactionId": 1,
  "reference": "Rent share"
}

Response 200:

{
  "success": true,
  "message": "",
  "data": {
    "transaction": {
      "id": 1,
      "amount": 150.00,
      "fee": 2.30,
      "vat": 0.35,
      "mobile_number": "0821234567",
      "beneficiary_name": "Sipho Dlamini",
      "note": "Lunch",
      "reference": "Rent share",
      "cashsend_id": "…",
      "withdrawal_number": "…",
      "status": "Completed",
      "failure_reason": null,
      "create_at": "2026-11-11T08:30:00Z",
      "updated_at": "2026-11-11T08:30:00Z"
    },
    "transDto": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "reason": null
  }
}

POST /api/cashsend/webhook

What it does: TransMS status updates (Initiated, Redeemed, Cancelled, Rejected). Requires the transaction's callback token; refunds on cancellation or rejection.

See: §12.8 · §13.13

Auth: None

Request (CashSendWebhookDto):

{
  "success": true,
  "statusText": "…",
  "apiRef": "…",
  "transactionRef": "…",
  "cashSendId": "…",
  "originalEndToEndId": "…",
  "originalUETR": "…",
  "originalInstructionId": "…",
  "amount": 150.00,
  "currency": "ZAR",
  "executionDate": "2026-11-11T08:30:00Z",
  "txStatusCode": "<code>",
  "txStatusReasonCode": null,
  "txStatusReasonText": null,
  "outcome": "…",
  "isInitiation": true,
  "isCancellation": true,
  "isRedeemed": true
}

Response 200:

{ "status": … }

GET /api/vouchers/all/{walletId}

What it does: The caller's wallet's Zakhaa vouchers (with PINs).

See: §12.8 · §13.12

Auth: Strict (user JWT + device signature)

Route: walletId (string)

Response 200:

{
  "success": true,
  "message": "",
  "data": [
    {
      "id": 1,
      "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "voucher": "483920175562",
      "pin": "1234",
      "walletId": "9471452626",
      "amount": 150.00,
      "fee": 2.30,
      "vat": 0.35,
      "redeemed": true,
      "reversed": true,
      "expired": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "expiresAt": "2026-11-11T08:30:00Z",
      "reversedAt": "2026-11-11T08:30:00Z",
      "redeemedAt": "2026-11-11T08:30:00Z"
    }
  ]
}

POST /api/vouchers/create

What it does: Creates a Zakhaa voucher from the caller's wallet: limits and fee ZAKVOUCHER + VAT, 12-digit number valid for 1 year, PIN encrypted at rest (13.12).

See: §12.8 · §13.12

Auth: Strict (user JWT + device signature)

Request (CreateZakhaaVoucherDTO):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "pin": "1234",
  "walletId": "9471452626",
  "amount": 150.00
}

Response 200:

{
  "success": true,
  "message": "",
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "voucher": {
      "id": 1,
      "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "voucher": "483920175562",
      "pin": "1234",
      "walletId": "9471452626",
      "amount": 150.00,
      "fee": 2.30,
      "vat": 0.35,
      "redeemed": true,
      "reversed": true,
      "expired": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "expiresAt": "2026-11-11T08:30:00Z",
      "reversedAt": "2026-11-11T08:30:00Z",
      "redeemedAt": "2026-11-11T08:30:00Z"
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    }
  }
}

POST /api/vouchers/redeem

What it does: Redeems a voucher (by number) into the caller's own wallet.

See: §12.8 · §13.12

Auth: Strict (user JWT + device signature)

Request (RedeemZakhaaVoucherDTO):

{
  "voucher": "483920175562",
  "walletId": "9471452626"
}

Response 200:

{
  "success": true,
  "message": "",
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "voucher": {
      "id": 1,
      "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "voucher": "483920175562",
      "pin": "1234",
      "walletId": "9471452626",
      "amount": 150.00,
      "fee": 2.30,
      "vat": 0.35,
      "redeemed": true,
      "reversed": true,
      "expired": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "expiresAt": "2026-11-11T08:30:00Z",
      "reversedAt": "2026-11-11T08:30:00Z",
      "redeemedAt": "2026-11-11T08:30:00Z"
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    }
  }
}

POST /api/vouchers/reverse

What it does: Cancels an unredeemed voucher and refunds the creator.

See: §12.8 · §13.12

Auth: Strict (user JWT + device signature)

Request (ReverseZakhaaVoucherDTO):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "voucher": "483920175562",
  "walletId": "9471452626"
}

Response 200:

{
  "success": true,
  "message": "",
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "voucher": {
      "id": 1,
      "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "voucher": "483920175562",
      "pin": "1234",
      "walletId": "9471452626",
      "amount": 150.00,
      "fee": 2.30,
      "vat": 0.35,
      "redeemed": true,
      "reversed": true,
      "expired": true,
      "createdAt": "2026-11-11T08:30:00Z",
      "expiresAt": "2026-11-11T08:30:00Z",
      "reversedAt": "2026-11-11T08:30:00Z",
      "redeemedAt": "2026-11-11T08:30:00Z"
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    }
  }
}

GET /api/vouchers/{voucher}

What it does: Looks up a voucher by number (status, amount, PIN).

See: §12.8 · §13.12

Auth: Strict (user JWT + device signature)

Route: voucher (string)

Response 200:

{
  "success": true,
  "message": "",
  "data": {
    "id": 1,
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "voucher": "483920175562",
    "pin": "1234",
    "walletId": "9471452626",
    "amount": 150.00,
    "fee": 2.30,
    "vat": 0.35,
    "redeemed": true,
    "reversed": true,
    "expired": true,
    "createdAt": "2026-11-11T08:30:00Z",
    "redeemedAt": "2026-11-11T08:30:00Z",
    "reversedAt": "2026-11-11T08:30:00Z",
    "expiresAt": "2026-11-11T08:30:00Z",
    "wallet": {
      "wallet_id": "9471452626",
      "user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "busid": "…",
      "balance": 150.00,
      "dailySpent": 1.00,
      "monthlySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "last_transaction": "2026-11-11T08:30:00Z",
      "updated_at": "2026-11-11T08:30:00Z",
      "created_at": "2026-11-11T08:30:00Z",
      "trust_tier": 1,
      "trust_tier_override": 1,
      "trust_tier_frozen": true,
      "tier_last_computed": "2026-11-11T08:30:00Z",
      "user": {
        "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "name": "Thandi",
        "surname": "Mokoena",
        "email": "thandi@example.com",
        "idType": "Payment",
        "idNumber": "9001015009087",
        "expoPushToken": "<token>",
        "phone": "0821234567",
        "password": "••••••",
        "roleId": 1,
        "salt": "…",
        "is_verified": true,
        "is_active": true,
        "need_review": true,
        "is_deleted": true,
        "created_at": "2026-11-11T08:30:00Z",
        "updated_at": "2026-11-11T08:30:00Z",
        "deleted_At": "2026-11-11T08:30:00Z",
        "last_Login": "2026-11-11T08:30:00Z",
        "location": "…",
        "postal_code": "<code>",
        "dob": "…",
        "emailStatus": true,
        "phoneStatus": true,
        "addresses": [ { … } ],
        "kyC_Profile": { … },
        "amL_Flags": [ { … } ],
        "wallets": [ { … } ],
        "cards": [ { … } ],
        "kins": [ { … } ],
        "settings": { … },
        "devices": [ { … } ],
        "notifications": [ { … } ],
        "notificationPreference": { … },
        "documents": [ { … } ],
        "businesses": [ { … } ],
        "zakhaaVouchers": [ { … } ],
        "userActivities": [ { … } ],
        "selectedUsers": [ { … } ],
        "recipients": [ { … } ],
        "displayAdEvents": [ { … } ],
        "userNotes": [ { … } ]
      },
      "business": {
        "busId": "…",
        "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "businessName": "Thandi",
        "streetAddress": "…",
        "suburb": "Braamfontein",
        "city": "Johannesburg",
        "province": "Gauteng",
        "postalCode": "2001",
        "mcc": "…",
        "businessType": "Payment",
        "accNum": "…",
        "email": "thandi@example.com",
        "isVerified": true,
        "user": { … },
        "wallet": { … }
      },
      "topups": [ { … } ],
      "wallet_withdrawal": [ { … } ],
      "vasTransactions": [ { … } ],
      "beneficiaries": [ { … } ],
      "payshapBeneficiary": [ { … } ],
      "zakhaaVouchers": [ { … } ],
      "zapperTransactions": [ { … } ],
      "payshapTransactions": [ { … } ]
    },
    "user": {
      "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "name": "Thandi",
      "surname": "Mokoena",
      "email": "thandi@example.com",
      "idType": "Payment",
      "idNumber": "9001015009087",
      "expoPushToken": "<token>",
      "phone": "0821234567",
      "password": "••••••",
      "roleId": 1,
      "salt": "…",
      "is_verified": true,
      "is_active": true,
      "need_review": true,
      "is_deleted": true,
      "created_at": "2026-11-11T08:30:00Z",
      "updated_at": "2026-11-11T08:30:00Z",
      "deleted_At": "2026-11-11T08:30:00Z",
      "last_Login": "2026-11-11T08:30:00Z",
      "location": "…",
      "postal_code": "<code>",
      "dob": "…",
      "emailStatus": true,
      "phoneStatus": true,
      "addresses": [ { … } ],
      "kyC_Profile": {
        "profile_id": 1,
        "user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "idNumber": "9001015009087",
        "id_type": "Payment",
        "id_doc": "<base64>",
        "selfie": "<base64>",
        "kyc_status": "Completed",
        "verified_at": "2026-11-11T08:30:00Z",
        "created_at": "2026-11-11T08:30:00Z",
        "user": { … }
      },
      "amL_Flags": [ { … } ],
      "wallets": [ { … } ],
      "cards": [ { … } ],
      "kins": [ { … } ],
      "settings": {
        "settingId": 1,
        "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "notifyOn": true,
        "darkMode": true,
        "notifyOnTransaction": true,
        "notifyOnKycStatus": true,
        "enableTwoFactorAuth": true,
        "preferredLanguage": "…",
        "updatedAt": "2026-11-11T08:30:00Z",
        "user": { … }
      },
      "devices": [ { … } ],
      "notifications": [ { … } ],
      "notificationPreference": {
        "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "user": { … },
        "globalOptIn": true,
        "smsEnabled": true,
        "pushEnabled": true,
        "emailEnabled": true,
        "inAppEnabled": true,
        "systemEnabled": true,
        "marketingEnabled": true,
        "promotionsEnabled": true,
        "remindersEnabled": true,
        "createdAt": "2026-11-11T08:30:00Z",
        "updatedAt": "2026-11-11T08:30:00Z"
      },
      "documents": [ { … } ],
      "businesses": [ { … } ],
      "zakhaaVouchers": [ { … } ],
      "userActivities": [ { … } ],
      "selectedUsers": [ { … } ],
      "recipients": [ { … } ],
      "displayAdEvents": [ { … } ],
      "userNotes": [ { … } ]
    }
  }
}

POST /api/payat/account/enquiry

What it does: Pay@ asks whether an account (13010 + wallet ID) exists; returns the owner's name for the till receipt and the R10–R15,000 limits.

See: §12.8 · §13.7

Auth: Policy ApiClient:PayAt; + token revocation check

Request (AccountEnquiryRequest):

{
  "accountNumber": "<account / meter / wallet number>",
  "messageId": "…",
  "networkData": {
    "id": "…",
    "name": "Thandi",
    "posData": {
      "storeId": "…",
      "terminalId": "…"
    }
  },
  "transmissionDateTime": "2026-11-11T08:30:00Z",
  "echoData": "…"
}

Response 200:

{
  "amount": 1,
  "customerData": {
    "customerAccountId": "…",
    "firstName": "Thandi",
    "lastName": "Thandi",
    "contactNumber": "…"
  },
  "issuerData": {
    "name": "Thandi",
    "contactNumber": "…",
    "taxNumber": "…"
  },
  "paymentRules": {
    "minAmount": 1,
    "maxAmount": 1,
    "acceptMultiplesOf": 1
  },
  "echoData": "…",
  "transmissionDateTime": "2026-11-11T08:30:00Z"
}

POST /api/payat/echoTest

What it does: Pay@ connectivity check.

See: §12.8 · §13.7

Auth: Policy ApiClient:PayAt; + token revocation check

Request (EchoTestRequest):

{
  "messageId": "…",
  "echoData": "…",
  "transmissionDateTime": "2026-11-11T08:30:00Z"
}

Response 200:

{
  "echoData": "…",
  "transmissionDateTime": "2026-11-11T08:30:00Z"
}

POST /api/payat/transaction/authorization

What it does: Pay@ authorises a cash deposit: checks the limits and wallet, records a pending top-up.

See: §12.8 · §13.7

Auth: Policy ApiClient:PayAt; + token revocation check

Request (TransactionAuthorizationRequest):

{
  "accountNumber": "<account / meter / wallet number>",
  "amount": 1,
  "messageId": "…",
  "transactionId": "…",
  "networkTransactionId": "…",
  "networkData": {
    "id": "…",
    "name": "Thandi",
    "posData": {
      "storeId": "…",
      "terminalId": "…"
    }
  },
  "transmissionDateTime": "2026-11-11T08:30:00Z",
  "echoData": "…"
}

Response 200:

{
  "issuerTransactionId": "…",
  "customerData": {
    "customerAccountId": "…",
    "firstName": "Thandi",
    "lastName": "Thandi",
    "contactNumber": "…"
  },
  "issuerData": {
    "name": "Thandi",
    "contactNumber": "…",
    "taxNumber": "…"
  },
  "echoData": "…",
  "transmissionDateTime": "2026-11-11T08:30:00Z"
}

POST /api/payat/transaction/completion

What it does: Pay@ confirms the cash was paid: credits the wallet (net of the DEPOSIT fee), idempotent, always answers so Pay@ stops retrying (13.7).

See: §12.8 · §13.7

Auth: Policy ApiClient:PayAt; + token revocation check

Request (TransactionCompletionRequest):

{
  "accountNumber": "<account / meter / wallet number>",
  "issuerTransactionId": "…",
  "messageId": "…",
  "transactionId": "…",
  "networkTransactionId": "…",
  "networkData": {
    "id": "…",
    "name": "Thandi",
    "posData": {
      "storeId": "…",
      "terminalId": "…"
    }
  },
  "tenders": [{
    "amount": 1,
    "tenderType": "Payment",
    "issuer": "…"
  }],
  "transmissionDateTime": "2026-11-11T08:30:00Z",
  "echoData": "…"
}

Response 200:

{
  "echoData": "…",
  "transmissionDateTime": "2026-11-11T08:30:00Z"
}

POST /api/payat/transaction/void

What it does: Pay@ cancels an authorised deposit.

See: §12.8 · §13.7

Auth: Policy ApiClient:PayAt; + token revocation check

Request (TransactionVoidRequest):

{
  "accountNumber": "<account / meter / wallet number>",
  "issuerTransactionId": "…",
  "messageId": "…",
  "transactionId": "…",
  "networkTransactionId": "…",
  "networkData": {
    "id": "…",
    "name": "Thandi",
    "posData": {
      "storeId": "…",
      "terminalId": "…"
    }
  },
  "voidReasonCode": null,
  "voidNetworkReasonCode": null,
  "voidReasonMessage": null,
  "transmissionDateTime": "2026-11-11T08:30:00Z",
  "echoData": "…"
}

Response 200:

{
  "echoData": "…",
  "transmissionDateTime": "2026-11-11T08:30:00Z"
}

20.9 VAS (Flash)

POST /api/flash/1voucher/disburse

What it does: Disburses value to a 1Voucher (debits the wallet).

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (Voucher1DisburseReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "amountInCents": 2900
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/1voucher/purchase

What it does: Buys a 1Voucher (debits the wallet; voucher PIN returned).

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (Voucher1PurchaseReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "amountInCents": 2900
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/1voucher/redeem

What it does: Redeems a 1Voucher into the wallet (credit, minus the 1VOUCHERREDEEM fee + VAT).

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (Voucher1RedeemReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "pin": "1234",
  "amountInCents": 2900
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/1voucher/refund

What it does: Refunds a 1Voucher redemption.

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (Voucher1RefundRedemptionReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "amountInCents": 2900,
  "mobileNumber": "0821234567"
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/1voucher/reverse

What it does: Reverses a 1Voucher purchase.

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (Voucher1ReverseReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "originalReference": "Rent share"
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/cash-out-pin/cancel

What it does: Cancels a cash-out PIN.

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (CashOutPinCancelReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "serialNumber": "…"
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/cash-out-pin/lookup

What it does: Looks up a cash-out PIN's status.

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (CashOutPinLookupReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "serialNumber": "…"
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/cash-out-pin/purchase

What it does: Buys a cash-out PIN (withdraw cash at partner retailers).

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (CashOutPinPurchaseReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "amountInCents": 2900
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/cash-out-pin/reverse

What it does: Reverses a cash-out PIN purchase.

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (CashOutPinReverseReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "originalReference": "Rent share"
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/cellular/pinless/purchase

What it does: Buys airtime or data for a phone number (debits the wallet; funds reserved, refunded if Flash fails).

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (CellularPinlessPurchaseReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "amountInCents": 2900,
  "mobileNumber": "0821234567"
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/eezi-voucher/purchase

What it does: Buys an Eezi voucher.

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (EeziVoucherPurchaseReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "amountInCents": 2900
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/eezi-voucher/reverse

What it does: Reverses an Eezi voucher purchase.

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (EeziVoucherReverseReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "originalReference": "Rent share"
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/flash-pay/disburse

What it does: Flash Pay disbursement.

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (FlashPayDisburseReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "accountReference": "Rent share",
  "amountInCents": 2900
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/flash-pay/purchase

What it does: Flash Pay purchase (pay a supported biller).

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (FlashPayPurchaseReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "accountReference": "Rent share",
  "amountInCents": 2900
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/flash-token/purchase

What it does: Buys a Flash token.

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (FlashTokenPurchaseReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "amountInCents": 2900
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/flash-token/reverse

What it does: Reverses a Flash token purchase.

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (FlashTokenReverseReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "originalReference": "Rent share"
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/gift-vouchers/purchase

What it does: Buys a retailer gift voucher.

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (GiftVouchersPurchaseReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "amountInCents": 2900
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/gift-vouchers/reverse

What it does: Reverses a gift voucher purchase.

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (GiftVouchersReverseReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "originalReference": "Rent share"
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

POST /api/flash/prepaid-utilities/purchase

What it does: Buys prepaid electricity for a meter number (token returned).

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Request (PrepaidUtilitiesPurchaseReq):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "walletId": "9471452626",
  "merchantAccountNumber": "0821234567",
  "productCode": "<Flash product code>",
  "reference": "Rent share",
  "paymentMethod": "wallet",
  "storeId": "…",
  "terminalId": "…",
  "metadata": { "key": "value" },
  "email": "thandi@example.com",
  "amountInCents": 2900,
  "meterNumber": "…"
}

Response 200:

{
  "success": true,
  "data": {
    "wallet": {
      "wallet_id": "9471452626",
      "balance": 150.00,
      "availableBalance": 150.00,
      "dailySpent": 1.00,
      "currency": "ZAR",
      "status": "Completed",
      "transactions": [ { … } ],
      "vouchers": [ { … } ]
    },
    "transaction": {
      "id": 1,
      "title": "Send Zakhaa",
      "wallet_id": "9471452626",
      "type": "Payment",
      "fee": 2.30,
      "vat": 0.35,
      "sender_id": "…",
      "receiver_id": "8312045577",
      "amount": 150.00,
      "tipAmount": 0.00,
      "failureReason": null,
      "method": "Send Zakhaa",
      "reference": "Rent share",
      "transaction_id": "…",
      "status": "Completed",
      "create_at": "2026-11-11T08:30:00Z"
    },
    "voucher": "483920175562",
    "data": { "key": "value" }
  }
}

GET /api/flash/product

What it does: One Flash product by code.

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Query: accountNumber (string?), productCode (string)

Response 200:

{
  "productCode": 1,
  "productName": "Thandi",
  "productGroup": "…",
  "minimumAmount": 1,
  "maximumAmount": 1,
  "fixedAmount": true,
  "allowReversal": true,
  "barcode": "<code>",
  "billerCode": "<code>",
  "status": "Completed",
  "vendor": "…",
  "raw": "…"
}

GET /api/flash/products

What it does: Flash products for the account, optionally by group.

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Query: accountNumber (string?), group (string?)

Response 200:

[
  {
    "productCode": 1,
    "productName": "Thandi",
    "productGroup": "…",
    "minimumAmount": 1,
    "maximumAmount": 1,
    "fixedAmount": true,
    "allowReversal": true,
    "barcode": "<code>",
    "billerCode": "<code>",
    "status": "Completed",
    "vendor": "…",
    "raw": "…"
  }
]

GET /api/flash/zakhaapay/getproducts/{category}

What it does: Zakhaa's active VAS vendors/products in a category (for the app's VAS screens).

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Route: category (string)

Response 200:

[
  {
    "vendor": "…",
    "logoUrl": "…",
    "products": [{
      "productCode": 1,
      "productName": "Thandi",
      "productGroup": "…",
      "minimumAmount": 1,
      "maximumAmount": 1,
      "fixedAmount": true,
      "allowReversal": true,
      "barcode": "<code>",
      "billerCode": "<code>",
      "status": "Completed",
      "vendor": "…",
      "raw": "…"
    }]
  }
]

GET /api/flash/zakhaapay/product

What it does: Zakhaa's active VAS products.

See: §12.9 · §13.9

Auth: Strict (user JWT + device signature)

Response 200:

[
  {
    "productCode": 1,
    "productName": "Thandi",
    "productGroup": "…",
    "minimumAmount": 1,
    "maximumAmount": 1,
    "fixedAmount": true,
    "allowReversal": true,
    "barcode": "<code>",
    "billerCode": "<code>",
    "status": "Completed",
    "vendor": "…",
    "raw": "…"
  }
]

20.10 Notifications, settings and content

GET /api/notifications

What it does: The caller's in-app notifications.

See: §12.10 · §13.19

Auth: Strict (user JWT + device signature)

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "notificationId": 1,
      "category": "Transaction",
      "eventType": "SendZakhaa",
      "title": "Send Zakhaa",
      "message": "…",
      "isRead": true,
      "isReadAt": "2026-11-11T08:30:00Z",
      "campaignId": 1,
      "relatedEntityId": 1,
      "relatedEntityType": "Payment",
      "createdAt": "2026-11-11T08:30:00Z"
    }
  ],
  "error": null
}

PUT /api/notifications/preferences

What it does: Updates the caller's notification categories.

See: §12.10 · §13.19

Auth: Strict (user JWT + device signature)

Request (UpdateNotificationPreferenceRequest):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "globalOptIn": true,
  "smsEnabled": true,
  "pushEnabled": true,
  "emailEnabled": true,
  "inAppEnabled": true,
  "systemEnabled": true,
  "marketingEnabled": true,
  "promotionsEnabled": true,
  "remindersEnabled": true
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "globalOptIn": true,
    "smsEnabled": true,
    "pushEnabled": true,
    "emailEnabled": true,
    "inAppEnabled": true,
    "systemEnabled": true,
    "marketingEnabled": true,
    "promotionsEnabled": true,
    "remindersEnabled": true,
    "createdAt": "2026-11-11T08:30:00Z",
    "updatedAt": "2026-11-11T08:30:00Z"
  },
  "error": null
}

PUT /api/notifications/push-token

What it does: Registers the device's Expo push token.

See: §12.10 · §13.19

Auth: Strict (user JWT + device signature)

Request (UpdateDevicePushTokenRequest):

{
  "deviceId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "expoPushToken": "<token>",
  "notificationsEnabled": true
}

Response 200:

{
  "isSuccess": true,
  "data": true,
  "error": null
}

PUT /api/notifications/read-all

What it does: Marks all the caller's notifications as read.

See: §12.10 · §13.19

Auth: Strict (user JWT + device signature)

Response 200:

{
  "isSuccess": true,
  "data": true,
  "error": null
}

PUT /api/notifications/read/{id}

What it does: Marks one notification as read.

See: §12.10 · §13.19

Auth: Strict (user JWT + device signature)

Route: id (int)

Response 200:

{
  "isSuccess": true,
  "data": {
    "notificationId": 1,
    "isRead": true
  },
  "error": null
}

PUT /api/notifications/{deviceId}/notifications

What it does: Turns push notifications on or off for one of the caller's devices.

See: §12.10 · §13.19

Auth: Strict (user JWT + device signature)

Route: deviceId (Guid)

Request (SetDeviceNotificationPreferenceRequest):

{
  "smsNotificationsEnabled": true,
  "pushNotificationsEnabled": true,
  "emailNotificationsEnable": true
}

Response 200:

{
  "isSuccess": true,
  "data": true,
  "error": null
}

POST /api/settings

What it does: Creates the caller's app settings.

See: §12.10 · §13.22 · §19.5

Auth: Strict (user JWT + device signature)

Request (CreateSettingsDto):

{
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "notifyOn": true,
  "darkMode": true,
  "notifyOnTransaction": true,
  "notifyOnKycStatus": true,
  "enableTwoFactorAuth": true,
  "preferredLanguage": "…",
  "updatedAt": "2026-11-11T08:30:00Z"
}

Response 200:

{
  "settingId": 1,
  "notifyOn": true,
  "darkMode": true,
  "notifyOnTransaction": true,
  "notifyOnKycStatus": true,
  "enableTwoFactorAuth": true,
  "preferredLanguage": "…",
  "updatedAt": "2026-11-11T08:30:00Z"
}

GET /api/settings/version

What it does: Public: the latest app version, whether an update is forced, and the store URLs (the app's update gate).

See: §12.10 · §13.22 · §19.5

Auth: None

Response 200:

{ "success": true, "data": { … } }

POST /api/settings/version

What it does: Sets one of the app-version keys (LatestAppVersion, ForceUpdate, UpdateUrlAndroid, UpdateUrlIOS).

See: §12.10 · §13.22 · §19.5

Auth: Roles: SuperAdmin, Admin

Request (AppSettingRequest):

{
  "key": "ForceUpdate",
  "value": "true"
}

Response 200:

{ "success": true, "data": { … } }

PUT /api/settings/{id}

What it does: Updates the caller's app settings (notifications, dark mode, language, …).

See: §12.10 · §13.22 · §19.5

Auth: Strict (user JWT + device signature)

Route: id (int)

Request (PutSettingsDto):

{
  "notifyOn": true,
  "darkMode": true,
  "notifyOnTransaction": true,
  "notifyOnKycStatus": true,
  "enableTwoFactorAuth": true,
  "preferredLanguage": "…",
  "updatedAt": "2026-11-11T08:30:00Z"
}

Response 200:

{
  "settingId": 1,
  "notifyOn": true,
  "darkMode": true,
  "notifyOnTransaction": true,
  "notifyOnKycStatus": true,
  "enableTwoFactorAuth": true,
  "preferredLanguage": "…",
  "updatedAt": "2026-11-11T08:30:00Z"
}

GET /api/settings/{uid}

What it does: The caller's app settings.

See: §12.10 · §13.22 · §19.5

Auth: Strict (user JWT + device signature)

Route: uid (Guid)

Response 200:

{
  "settingId": 1,
  "notifyOn": true,
  "darkMode": true,
  "notifyOnTransaction": true,
  "notifyOnKycStatus": true,
  "enableTwoFactorAuth": true,
  "preferredLanguage": "…",
  "updatedAt": "2026-11-11T08:30:00Z"
}

GET /api/mobile/display-ads/events

What it does: In-app ad events.

See: §12.10 · §13.20

Auth: Strict (user JWT + device signature)

Response 200:

[
  {
    "id": 1,
    "displayAdId": 1,
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "deviceId": "…",
    "eventType": "Impression",
    "actionName": "Thandi",
    "targetUrl": "…",
    "deepLink": "…",
    "eventAtUtc": "2026-11-11T08:30:00Z",
    "displayAd": {
      "id": 1,
      "campaignId": 1,
      "placement": "Popup",
      "htmlBody": "…",
      "priority": 1,
      "frequencyDays": 1,
      "startAtUtc": "2026-11-11T08:30:00Z",
      "endAtUtc": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z",
      "isActive": true,
      "campaign": {
        "id": 1,
        "internalName": "Thandi",
        "titleOrSubject": "…",
        "body": "…",
        "contentFormat": "PlainText",
        "audienceType": "AllUsers",
        "category": "Transaction",
        "audienceDefinition": { … },
        "platformType": "Push",
        "status": "Draft",
        "createdByAdminId": "…",
        "createdAtUtc": "2026-11-11T08:30:00Z",
        "updatedAtUtc": "2026-11-11T08:30:00Z",
        "lastError": null,
        "createdByAdmin": { … },
        "schedule": { … },
        "displayAd": { … },
        "runs": [ { … } ],
        "selectedUsers": [ { … } ],
        "media": [ { … } ],
        "suppressions": [ { … } ],
        "externalImportBatch": { … }
      },
      "displayAdEvents": [ { … } ]
    },
    "user": {
      "uid": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "name": "Thandi",
      "surname": "Mokoena",
      "email": "thandi@example.com",
      "idType": "Payment",
      "idNumber": "9001015009087",
      "expoPushToken": "<token>",
      "phone": "0821234567",
      "password": "••••••",
      "roleId": 1,
      "salt": "…",
      "is_verified": true,
      "is_active": true,
      "need_review": true,
      "is_deleted": true,
      "created_at": "2026-11-11T08:30:00Z",
      "updated_at": "2026-11-11T08:30:00Z",
      "deleted_At": "2026-11-11T08:30:00Z",
      "last_Login": "2026-11-11T08:30:00Z",
      "location": "…",
      "postal_code": "<code>",
      "dob": "…",
      "emailStatus": true,
      "phoneStatus": true,
      "addresses": [ { … } ],
      "kyC_Profile": {
        "profile_id": 1,
        "user_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "idNumber": "9001015009087",
        "id_type": "Payment",
        "id_doc": "<base64>",
        "selfie": "<base64>",
        "kyc_status": "Completed",
        "verified_at": "2026-11-11T08:30:00Z",
        "created_at": "2026-11-11T08:30:00Z",
        "user": { … }
      },
      "amL_Flags": [ { … } ],
      "wallets": [ { … } ],
      "cards": [ { … } ],
      "kins": [ { … } ],
      "settings": {
        "settingId": 1,
        "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "notifyOn": true,
        "darkMode": true,
        "notifyOnTransaction": true,
        "notifyOnKycStatus": true,
        "enableTwoFactorAuth": true,
        "preferredLanguage": "…",
        "updatedAt": "2026-11-11T08:30:00Z",
        "user": { … }
      },
      "devices": [ { … } ],
      "notifications": [ { … } ],
      "notificationPreference": {
        "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "user": { … },
        "globalOptIn": true,
        "smsEnabled": true,
        "pushEnabled": true,
        "emailEnabled": true,
        "inAppEnabled": true,
        "systemEnabled": true,
        "marketingEnabled": true,
        "promotionsEnabled": true,
        "remindersEnabled": true,
        "createdAt": "2026-11-11T08:30:00Z",
        "updatedAt": "2026-11-11T08:30:00Z"
      },
      "documents": [ { … } ],
      "businesses": [ { … } ],
      "zakhaaVouchers": [ { … } ],
      "userActivities": [ { … } ],
      "selectedUsers": [ { … } ],
      "recipients": [ { … } ],
      "displayAdEvents": [ { … } ],
      "userNotes": [ { … } ]
    }
  }
]

POST /api/mobile/display-ads/events

What it does: Records an ad impression or click.

See: §12.10 · §13.20

Auth: Strict (user JWT + device signature)

Request (PostDisplayAdsEventsRequest):

{
  "displayAdId": 1,
  "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "deviceId": "…",
  "eventType": "Impression",
  "actionName": "Thandi",
  "targetUrl": "…",
  "deepLink": "…",
  "eventAtUtc": "2026-11-11T08:30:00Z"
}

Response 200:

{
  "isSuccess": true,
  "data": true,
  "error": null
}

GET /api/mobile/display-ads/home

What it does: Ads for the app's home screen.

See: §12.10 · §13.20

Auth: Strict (user JWT + device signature)

Response 200:

{
  "isSuccess": true,
  "data": {
    "popUps": [{
      "id": 1,
      "campaignId": 1,
      "placement": "Popup",
      "htmlBody": "…",
      "priority": 1,
      "frequencyDays": 1,
      "startAtUtc": "2026-11-11T08:30:00Z",
      "endAtUtc": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z",
      "isActive": true
    }],
    "carousels": [{
      "id": 1,
      "campaignId": 1,
      "placement": "Popup",
      "htmlBody": "…",
      "priority": 1,
      "frequencyDays": 1,
      "startAtUtc": "2026-11-11T08:30:00Z",
      "endAtUtc": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z",
      "isActive": true
    }]
  },
  "error": null
}

GET /api/WebsitePopUpMessages

What it does: All website/app pop-up messages.

See: §12.10 · §13.20

Auth: None

Response 200:

{
  "success": true,
  "message": "",
  "data": …
}

POST /api/WebsitePopUpMessages

What it does: Adds a pop-up message.

See: §12.10 · §13.20

Auth: Roles: Admin, SuperAdmin

Request (WebsitePopUpMessage):

{
  "id": 1,
  "body": "…",
  "isActive": true,
  "targetPaths": ["…"],
  "target": "…",
  "popup": true,
  "frequencyDays": 1,
  "createdAt": "2026-11-11T08:30:00Z"
}

Response 200:

{
  "success": true,
  "message": "",
  "data": …
}

GET /api/WebsitePopUpMessages/active

What it does: Currently active pop-up messages.

See: §12.10 · §13.20

Auth: None

Response 200:

{
  "success": true,
  "message": "",
  "data": …
}

GET /api/WebsitePopUpMessages/active/android

What it does: Active pop-up messages for the Android app.

See: §12.10 · §13.20

Auth: None

Response 200:

[
  {
    "id": 1,
    "body": "…",
    "isActive": true,
    "targetPaths": ["…"],
    "target": "…",
    "popup": true,
    "frequencyDays": 1,
    "createdAt": "2026-11-11T08:30:00Z"
  }
]

GET /api/WebsitePopUpMessages/active/{target}

What it does: Active pop-up messages for a target (website, app, …).

See: §12.10 · §13.20

Auth: None

Route: target (string)

Response 200:

{
  "success": true,
  "message": "",
  "data": …
}

GET /api/WebsitePopUpMessages/{id}

What it does: One pop-up message.

See: §12.10 · §13.20

Auth: None

Route: id (int)

Response 200:

{
  "success": true,
  "message": "",
  "data": …
}

GET /api/links/Tsandcs

What it does: Link to the terms and conditions.

See: §12.10

Auth: None

Response 200:

"https://zakhaapay.com/ts&cs.html"

GET /api/downloads/latest

What it does: Downloads the latest Android APK from the server.

See: §12.10

Auth: None

Response 200: file download, application/vnd.android.package-archive (zakhaapay.apk).

GET /api/support/tawk-login

What it does: Tawk.to chat identity hash (HMAC of the user ID with the Tawk key) so support chat is tied to the user.

See: §12.10

Auth: Strict (user JWT + device signature)

Response 200:

{
  "userId": "…",
  "hash": "…"
}

POST /api/SendEmail/Custom

What it does: Sends an email with optional attachment. No authentication (open issue, §16.1).

See: §12.10

Auth: None

Request (EmailData):

{
  "to": "…",
  "emails": ["…"],
  "from": "…",
  "merchant": "…",
  "attachment": "…",
  "attachmentName": "Thandi",
  "token": "<token>",
  "body": "…",
  "pos": true
}

Response 200:

{
  "success": true,
  "message": "",
  "data": null
}

20.11 Admin operations: reconciliation and campaigns

POST /api/reconciliation/apply-topup

What it does: Credits an unmatched bank deposit to a wallet (manual_topup ledger row), rejects duplicate references, audits and notifies the user (13.17).

See: §12.11 · §13.17

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Request (ManualTopupDto):

{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "statementId": "…",
  "walletId": "9471452626",
  "amount": 150.00,
  "fee": 2.30,
  "vat": 0.35,
  "reference": "Rent share",
  "createdAt": "2026-11-11T08:30:00Z",
  "email": "thandi@example.com",
  "name": "Thandi"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "statementId": "…",
    "walletId": "9471452626",
    "amount": 150.00,
    "fee": 2.30,
    "vat": 0.35,
    "reference": "Rent share",
    "createdAt": "2026-11-11T08:30:00Z",
    "email": "thandi@example.com",
    "name": "Thandi"
  },
  "error": null
}

POST /api/reconciliation/requestpop

What it does: Emails a customer asking for proof of payment.

See: §12.11 · §13.17

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Request (EmailDto):

{
  "emails": [{
    "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "email": "thandi@example.com"
  }]
}

Response 200:

{
  "isSuccess": true,
  "data": "…",
  "error": null
}

GET /api/reconciliation/unmatched-credits

What it does: Bank statement credits with no matching top-up, paged and searchable.

See: §12.11 · §13.17

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Query: search (string?), page (int), pageSize (int)

Response 200:

{
  "isSuccess": true,
  "data": {
    "items": [
      {
        "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "statementId": "…",
        "transactionId": "…",
        "amount": 150.00,
        "createdAt": "2026-11-11T08:30:00Z",
        "reference": "Rent share"
      }
    ],
    "totalRecords": 1,
    "totalPages": 1,
    "currentPage": 1,
    "pageSize": 20,
    "hasNextPage": false,
    "hasPreviousPage": false
  },
  "error": null
}

GET /api/reconciliation/unmatched-credits/{id}/possible-users

What it does: Candidate customers for an unmatched credit, scored from the statement reference.

See: §12.11 · §13.17

Auth: Roles: SuperAdmin, Admin, SupportAdmin

Route: id (Guid)

Query: search (string?), page (int), pageSize (int)

Response 200:

{
  "isSuccess": true,
  "data": {
    "items": [
      {
        "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "name": "Thandi",
        "surname": "Mokoena",
        "email": "thandi@example.com",
        "phone": "0821234567",
        "walletId": "9471452626",
        "matchScore": 1
      }
    ],
    "totalRecords": 1,
    "totalPages": 1,
    "currentPage": 1,
    "pageSize": 20,
    "hasNextPage": false,
    "hasPreviousPage": false
  },
  "error": null
}

GET /api/admin/campaigns

What it does: Marketing campaigns.

See: §12.11 · §13.20

Auth: Web JWT

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "id": 1,
      "internalName": "Thandi",
      "title": "Send Zakhaa",
      "body": "…",
      "audienceType": "AllUsers",
      "category": "Transaction",
      "platformType": "Push",
      "status": "Draft",
      "createdAtUtc": "2026-11-11T08:30:00Z"
    }
  ],
  "error": null
}

POST /api/admin/campaigns

What it does: Creates a campaign (channel, audience, content, schedule) as a draft.

See: §12.11 · §13.20

Auth: Web JWT

Body: application/x-www-form-urlencoded with request.

Response 200:

{
  "isSuccess": true,
  "data": {
    "campaign": {
      "id": 1,
      "internalName": "Thandi",
      "title": "Send Zakhaa",
      "body": "…",
      "audienceType": "AllUsers",
      "category": "Transaction",
      "platformType": "Push",
      "status": "Draft",
      "createdAtUtc": "2026-11-11T08:30:00Z"
    },
    "importSummary": {
      "importBatchId": 1,
      "totalRows": 1,
      "eligibleRows": 1,
      "existingUserRows": 1,
      "duplicateRows": 1,
      "invalidRows": 1,
      "suppressedRows": 1,
      "issues": [ { … } ]
    }
  },
  "error": null
}

POST /api/admin/campaigns/external/subscribe

What it does: Public: re-subscribes an external contact.

See: §12.11 · §13.20

Auth: None

Request (ExternalContactSubscriptionRequest):

{
  "destination": "Email",
  "value": "true",
  "campaignId": 1,
  "channel": "…"
}

Response 200:

{
  "isSuccess": true,
  "data": true,
  "error": null
}

POST /api/admin/campaigns/external/unsubscribe

What it does: Public: unsubscribes an external contact (suppression list).

See: §12.11 · §13.20

Auth: None

Request (ExternalContactSubscriptionRequest):

{
  "destination": "Email",
  "value": "true",
  "campaignId": 1,
  "channel": "…"
}

Response 200:

{
  "isSuccess": true,
  "data": true,
  "error": null
}

POST /api/admin/campaigns/media

What it does: Uploads campaign media to S3.

See: §12.11 · §13.20

Auth: Web JWT

Query: campaignId (long)

Body: application/x-www-form-urlencoded with request.

Response 200:

{
  "isSuccess": true,
  "data": {
    "campaignId": 1,
    "contentType": "Payment",
    "originalFileName": "Thandi",
    "displayOrder": 1,
    "altText": "…",
    "clickUrl": "…"
  },
  "error": null
}

DELETE /api/admin/campaigns/media/{mediaId}

What it does: Deletes campaign media.

See: §12.11 · §13.20

Auth: Web JWT

Route: mediaId (int)

Response 200:

{
  "isSuccess": true,
  "data": {
    "campaignId": 1,
    "contentType": "Payment",
    "originalFileName": "Thandi",
    "displayOrder": 1,
    "altText": "…",
    "clickUrl": "…"
  },
  "error": null
}

POST /api/admin/campaigns/run/{runId}/retry

What it does: Retries a failed campaign run.

See: §12.11 · §13.20

Auth: Web JWT

Route: runId (Guid)

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "campaignId": 1,
    "status": "Pending",
    "attemptNumber": 1,
    "targetedCount": 1,
    "acceptedCount": 1,
    "failedCount": 1,
    "lastError": null,
    "scheduledForUtc": "2026-11-11T08:30:00Z",
    "startedAtUtc": "2026-11-11T08:30:00Z",
    "completedAtUtc": "2026-11-11T08:30:00Z",
    "createdAtUtc": "2026-11-11T08:30:00Z",
    "recipients": [{
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "eligibilityStatus": "Eligible",
      "status": "Pending",
      "sentAt": "2026-11-11T08:30:00Z",
      "exclusionReason": "Customer request",
      "delivery": {
        "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "recipientId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "providerMessageId": "…",
        "status": "Pending",
        "retryCount": 1,
        "errorCode": "<code>",
        "errorMessage": "…",
        "attemptedAtUtc": "2026-11-11T08:30:00Z",
        "deliveredAtUtc": "2026-11-11T08:30:00Z"
      }
    }]
  },
  "error": null
}

GET /api/admin/campaigns/runs/{runId}

What it does: One campaign run with its delivery results.

See: §12.11 · §13.20

Auth: Web JWT

Route: runId (Guid)

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "campaignId": 1,
    "status": "Pending",
    "attemptNumber": 1,
    "targetedCount": 1,
    "acceptedCount": 1,
    "failedCount": 1,
    "lastError": null,
    "scheduledForUtc": "2026-11-11T08:30:00Z",
    "startedAtUtc": "2026-11-11T08:30:00Z",
    "completedAtUtc": "2026-11-11T08:30:00Z",
    "createdAtUtc": "2026-11-11T08:30:00Z",
    "recipients": [{
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "eligibilityStatus": "Eligible",
      "status": "Pending",
      "sentAt": "2026-11-11T08:30:00Z",
      "exclusionReason": "Customer request",
      "delivery": {
        "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "recipientId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "providerMessageId": "…",
        "status": "Pending",
        "retryCount": 1,
        "errorCode": "<code>",
        "errorMessage": "…",
        "attemptedAtUtc": "2026-11-11T08:30:00Z",
        "deliveredAtUtc": "2026-11-11T08:30:00Z"
      }
    }]
  },
  "error": null
}

GET /api/admin/campaigns/{campaignId}

What it does: One campaign.

See: §12.11 · §13.20

Auth: Web JWT

Route: campaignId (long)

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": 1,
    "internalName": "Thandi",
    "title": "Send Zakhaa",
    "body": "…",
    "contentFormat": "PlainText",
    "audienceType": "AllUsers",
    "category": "Transaction",
    "audienceDefinition": {
      "version": "…",
      "filters": {
        "is_active": true,
        "is_verified": true,
        "roles": [1],
        "cities": ["…"],
        "createdFrom": "2026-11-11T08:30:00Z",
        "createdTo": "2026-11-11T08:30:00Z",
        "lastActiveFrom": "2026-11-11T08:30:00Z",
        "lastActiveTo": "2026-11-11T08:30:00Z"
      },
      "permissions": {
        "requireGlobalOptIn": true,
        "requireChannelOptIn": true,
        "requireCategoryOptIn": true
      },
      "deliveryRequirements": {
        "requireDestinationForPlatform": true
      },
      "externalAudience": {
        "importBatchId": 1,
        "excludeRegisteredUsers": true,
        "excludeSuppressed": true,
        "dedupeBy": "…"
      }
    },
    "platformType": "Push",
    "status": "Draft",
    "createdAtUtc": "2026-11-11T08:30:00Z",
    "updatedAtUtc": "2026-11-11T08:30:00Z",
    "lastError": null,
    "schedule": {
      "id": 1,
      "campaignId": 1,
      "frequencyType": "Once",
      "runQuantity": 1,
      "startAtUtc": "2026-11-11T08:30:00Z",
      "endAtUtc": "2026-11-11T08:30:00Z",
      "nextRunAtUtc": "2026-11-11T08:30:00Z",
      "isActive": true
    },
    "createdByAdmin": {
      "id": "…",
      "firstName": "Thandi",
      "lastName": "Thandi",
      "email": "thandi@example.com",
      "contactNumber": "…",
      "department": "…",
      "lastLoginUtc": "2026-11-11T08:30:00Z",
      "createdAtUtc": "2026-11-11T08:30:00Z",
      "updatedAtUtc": "2026-11-11T08:30:00Z",
      "passwordInitialChange": true,
      "isActive": true,
      "roles": "…"
    },
    "media": [{
      "campaignId": 1,
      "contentType": "Payment",
      "originalFileName": "Thandi",
      "displayOrder": 1,
      "altText": "…",
      "clickUrl": "…"
    }],
    "selectedUsers": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"],
    "displayAd": {
      "id": 1,
      "campaignId": 1,
      "placement": "Popup",
      "htmlBody": "…",
      "priority": 1,
      "frequencyDays": 1,
      "startAtUtc": "2026-11-11T08:30:00Z",
      "endAtUtc": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z"
    }
  },
  "error": null
}

PUT /api/admin/campaigns/{campaignId}

What it does: Edits a campaign.

See: §12.11 · §13.20

Auth: Web JWT

Route: campaignId (long)

Request (UpdateMarketingCampaignDto):

{
  "internalName": "Thandi",
  "title": "Send Zakhaa",
  "body": "…",
  "contentFormat": "PlainText",
  "audienceType": "AllUsers",
  "platformType": "Push",
  "category": "Transaction",
  "audienceDefinition": {
    "version": "…",
    "filters": {
      "is_active": true,
      "is_verified": true,
      "roles": [1],
      "cities": ["…"],
      "createdFrom": "2026-11-11T08:30:00Z",
      "createdTo": "2026-11-11T08:30:00Z",
      "lastActiveFrom": "2026-11-11T08:30:00Z",
      "lastActiveTo": "2026-11-11T08:30:00Z"
    },
    "permissions": {
      "requireGlobalOptIn": true,
      "requireChannelOptIn": true,
      "requireCategoryOptIn": true
    },
    "deliveryRequirements": {
      "requireDestinationForPlatform": true
    },
    "externalAudience": {
      "importBatchId": 1,
      "excludeRegisteredUsers": true,
      "excludeSuppressed": true,
      "dedupeBy": "…"
    }
  }
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": 1,
    "internalName": "Thandi",
    "title": "Send Zakhaa",
    "body": "…",
    "contentFormat": "PlainText",
    "audienceType": "AllUsers",
    "category": "Transaction",
    "audienceDefinition": {
      "version": "…",
      "filters": {
        "is_active": true,
        "is_verified": true,
        "roles": [1],
        "cities": ["…"],
        "createdFrom": "2026-11-11T08:30:00Z",
        "createdTo": "2026-11-11T08:30:00Z",
        "lastActiveFrom": "2026-11-11T08:30:00Z",
        "lastActiveTo": "2026-11-11T08:30:00Z"
      },
      "permissions": {
        "requireGlobalOptIn": true,
        "requireChannelOptIn": true,
        "requireCategoryOptIn": true
      },
      "deliveryRequirements": {
        "requireDestinationForPlatform": true
      },
      "externalAudience": {
        "importBatchId": 1,
        "excludeRegisteredUsers": true,
        "excludeSuppressed": true,
        "dedupeBy": "…"
      }
    },
    "platformType": "Push",
    "status": "Draft",
    "createdAtUtc": "2026-11-11T08:30:00Z",
    "updatedAtUtc": "2026-11-11T08:30:00Z",
    "lastError": null
  },
  "error": null
}

POST /api/admin/campaigns/{campaignId}/cancel

What it does: Cancels a campaign and its pending runs.

See: §12.11 · §13.20

Auth: Web JWT

Route: campaignId (long)

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": 1,
    "internalName": "Thandi",
    "title": "Send Zakhaa",
    "body": "…",
    "contentFormat": "PlainText",
    "audienceType": "AllUsers",
    "category": "Transaction",
    "audienceDefinition": {
      "version": "…",
      "filters": {
        "is_active": true,
        "is_verified": true,
        "roles": [1],
        "cities": ["…"],
        "createdFrom": "2026-11-11T08:30:00Z",
        "createdTo": "2026-11-11T08:30:00Z",
        "lastActiveFrom": "2026-11-11T08:30:00Z",
        "lastActiveTo": "2026-11-11T08:30:00Z"
      },
      "permissions": {
        "requireGlobalOptIn": true,
        "requireChannelOptIn": true,
        "requireCategoryOptIn": true
      },
      "deliveryRequirements": {
        "requireDestinationForPlatform": true
      },
      "externalAudience": {
        "importBatchId": 1,
        "excludeRegisteredUsers": true,
        "excludeSuppressed": true,
        "dedupeBy": "…"
      }
    },
    "platformType": "Push",
    "status": "Draft",
    "createdAtUtc": "2026-11-11T08:30:00Z",
    "updatedAtUtc": "2026-11-11T08:30:00Z",
    "lastError": null,
    "schedule": {
      "id": 1,
      "campaignId": 1,
      "frequencyType": "Once",
      "runQuantity": 1,
      "startAtUtc": "2026-11-11T08:30:00Z",
      "endAtUtc": "2026-11-11T08:30:00Z",
      "nextRunAtUtc": "2026-11-11T08:30:00Z",
      "isActive": true
    },
    "createdByAdmin": {
      "id": "…",
      "firstName": "Thandi",
      "lastName": "Thandi",
      "email": "thandi@example.com",
      "contactNumber": "…",
      "department": "…",
      "lastLoginUtc": "2026-11-11T08:30:00Z",
      "createdAtUtc": "2026-11-11T08:30:00Z",
      "updatedAtUtc": "2026-11-11T08:30:00Z",
      "passwordInitialChange": true,
      "isActive": true,
      "roles": "…"
    },
    "media": [{
      "campaignId": 1,
      "contentType": "Payment",
      "originalFileName": "Thandi",
      "displayOrder": 1,
      "altText": "…",
      "clickUrl": "…"
    }],
    "selectedUsers": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"],
    "displayAd": {
      "id": 1,
      "campaignId": 1,
      "placement": "Popup",
      "htmlBody": "…",
      "priority": 1,
      "frequencyDays": 1,
      "startAtUtc": "2026-11-11T08:30:00Z",
      "endAtUtc": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z"
    }
  },
  "error": null
}

GET /api/admin/campaigns/{campaignId}/media

What it does: A campaign's media.

See: §12.11 · §13.20

Auth: Web JWT

Route: campaignId (long)

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "campaignId": 1,
      "contentType": "Payment",
      "originalFileName": "Thandi",
      "displayOrder": 1,
      "altText": "…",
      "clickUrl": "…"
    }
  ],
  "error": null
}

POST /api/admin/campaigns/{campaignId}/queue

What it does: Queues a campaign for sending (the scheduler picks it up).

See: §12.11 · §13.20

Auth: Web JWT

Route: campaignId (long)

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": 1,
    "internalName": "Thandi",
    "title": "Send Zakhaa",
    "body": "…",
    "contentFormat": "PlainText",
    "audienceType": "AllUsers",
    "category": "Transaction",
    "audienceDefinition": {
      "version": "…",
      "filters": {
        "is_active": true,
        "is_verified": true,
        "roles": [1],
        "cities": ["…"],
        "createdFrom": "2026-11-11T08:30:00Z",
        "createdTo": "2026-11-11T08:30:00Z",
        "lastActiveFrom": "2026-11-11T08:30:00Z",
        "lastActiveTo": "2026-11-11T08:30:00Z"
      },
      "permissions": {
        "requireGlobalOptIn": true,
        "requireChannelOptIn": true,
        "requireCategoryOptIn": true
      },
      "deliveryRequirements": {
        "requireDestinationForPlatform": true
      },
      "externalAudience": {
        "importBatchId": 1,
        "excludeRegisteredUsers": true,
        "excludeSuppressed": true,
        "dedupeBy": "…"
      }
    },
    "platformType": "Push",
    "status": "Draft",
    "createdAtUtc": "2026-11-11T08:30:00Z",
    "updatedAtUtc": "2026-11-11T08:30:00Z",
    "lastError": null,
    "schedule": {
      "id": 1,
      "campaignId": 1,
      "frequencyType": "Once",
      "runQuantity": 1,
      "startAtUtc": "2026-11-11T08:30:00Z",
      "endAtUtc": "2026-11-11T08:30:00Z",
      "nextRunAtUtc": "2026-11-11T08:30:00Z",
      "isActive": true
    },
    "createdByAdmin": {
      "id": "…",
      "firstName": "Thandi",
      "lastName": "Thandi",
      "email": "thandi@example.com",
      "contactNumber": "…",
      "department": "…",
      "lastLoginUtc": "2026-11-11T08:30:00Z",
      "createdAtUtc": "2026-11-11T08:30:00Z",
      "updatedAtUtc": "2026-11-11T08:30:00Z",
      "passwordInitialChange": true,
      "isActive": true,
      "roles": "…"
    },
    "media": [{
      "campaignId": 1,
      "contentType": "Payment",
      "originalFileName": "Thandi",
      "displayOrder": 1,
      "altText": "…",
      "clickUrl": "…"
    }],
    "selectedUsers": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"],
    "displayAd": {
      "id": 1,
      "campaignId": 1,
      "placement": "Popup",
      "htmlBody": "…",
      "priority": 1,
      "frequencyDays": 1,
      "startAtUtc": "2026-11-11T08:30:00Z",
      "endAtUtc": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z"
    }
  },
  "error": null
}

GET /api/admin/campaigns/{campaignId}/reach-estimate

What it does: How many recipients the audience currently matches.

See: §12.11 · §13.20

Auth: Web JWT

Route: campaignId (long)

Response 200:

{
  "isSuccess": true,
  "data": {
    "totalMatchedUsers": 1,
    "eligibleRecipients": 1,
    "excludedRecipients": 1,
    "excludedGlobalOptOut": 1,
    "excludedChannelOptOut": 1,
    "excludedCategoryOptOut": 1,
    "excludedInactiveUsers": 1,
    "excludedMissingDestination": 1,
    "estimatedAt": "2026-11-11T08:30:00Z"
  },
  "error": null
}

POST /api/admin/campaigns/{campaignId}/retry

What it does: Retries a campaign's failed deliveries.

See: §12.11 · §13.20

Auth: Web JWT

Route: campaignId (long)

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": 1,
    "internalName": "Thandi",
    "title": "Send Zakhaa",
    "body": "…",
    "contentFormat": "PlainText",
    "audienceType": "AllUsers",
    "category": "Transaction",
    "audienceDefinition": {
      "version": "…",
      "filters": {
        "is_active": true,
        "is_verified": true,
        "roles": [1],
        "cities": ["…"],
        "createdFrom": "2026-11-11T08:30:00Z",
        "createdTo": "2026-11-11T08:30:00Z",
        "lastActiveFrom": "2026-11-11T08:30:00Z",
        "lastActiveTo": "2026-11-11T08:30:00Z"
      },
      "permissions": {
        "requireGlobalOptIn": true,
        "requireChannelOptIn": true,
        "requireCategoryOptIn": true
      },
      "deliveryRequirements": {
        "requireDestinationForPlatform": true
      },
      "externalAudience": {
        "importBatchId": 1,
        "excludeRegisteredUsers": true,
        "excludeSuppressed": true,
        "dedupeBy": "…"
      }
    },
    "platformType": "Push",
    "status": "Draft",
    "createdAtUtc": "2026-11-11T08:30:00Z",
    "updatedAtUtc": "2026-11-11T08:30:00Z",
    "lastError": null,
    "schedule": {
      "id": 1,
      "campaignId": 1,
      "frequencyType": "Once",
      "runQuantity": 1,
      "startAtUtc": "2026-11-11T08:30:00Z",
      "endAtUtc": "2026-11-11T08:30:00Z",
      "nextRunAtUtc": "2026-11-11T08:30:00Z",
      "isActive": true
    },
    "createdByAdmin": {
      "id": "…",
      "firstName": "Thandi",
      "lastName": "Thandi",
      "email": "thandi@example.com",
      "contactNumber": "…",
      "department": "…",
      "lastLoginUtc": "2026-11-11T08:30:00Z",
      "createdAtUtc": "2026-11-11T08:30:00Z",
      "updatedAtUtc": "2026-11-11T08:30:00Z",
      "passwordInitialChange": true,
      "isActive": true,
      "roles": "…"
    },
    "media": [{
      "campaignId": 1,
      "contentType": "Payment",
      "originalFileName": "Thandi",
      "displayOrder": 1,
      "altText": "…",
      "clickUrl": "…"
    }],
    "selectedUsers": ["3fa85f64-5717-4562-b3fc-2c963f66afa6"],
    "displayAd": {
      "id": 1,
      "campaignId": 1,
      "placement": "Popup",
      "htmlBody": "…",
      "priority": 1,
      "frequencyDays": 1,
      "startAtUtc": "2026-11-11T08:30:00Z",
      "endAtUtc": "2026-11-11T08:30:00Z",
      "updatedAt": "2026-11-11T08:30:00Z"
    }
  },
  "error": null
}

GET /api/admin/campaigns/{campaignId}/runs

What it does: A campaign's runs.

See: §12.11 · §13.20

Auth: Web JWT

Route: campaignId (long)

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
      "campaignId": 1,
      "status": "Pending",
      "attemptNumber": 1,
      "targetedCount": 1,
      "acceptedCount": 1,
      "failedCount": 1,
      "lastError": null,
      "scheduledForUtc": "2026-11-11T08:30:00Z",
      "startedAtUtc": "2026-11-11T08:30:00Z",
      "completedAtUtc": "2026-11-11T08:30:00Z",
      "createdAtUtc": "2026-11-11T08:30:00Z",
      "recipients": [{
        "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "userId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
        "eligibilityStatus": "Eligible",
        "status": "Pending",
        "sentAt": "2026-11-11T08:30:00Z",
        "exclusionReason": "Customer request",
        "delivery": {
          "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
          "recipientId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
          "providerMessageId": "…",
          "status": "Pending",
          "retryCount": 1,
          "errorCode": "<code>",
          "errorMessage": "…",
          "attemptedAtUtc": "2026-11-11T08:30:00Z",
          "deliveredAtUtc": "2026-11-11T08:30:00Z"
        }
      }]
    }
  ],
  "error": null
}

PUT /api/admin/campaigns/{campaignId}/schedule/update

What it does: Changes a campaign's schedule (once, daily, weekly, monthly, yearly).

See: §12.11 · §13.20

Auth: Web JWT

Route: campaignId (long)

Request (ScheduleUpdateRequest):

{
  "frequencyType": "Once",
  "runQuantity": 1,
  "startAtUtc": "2026-11-11T08:30:00Z",
  "endAtUtc": "2026-11-11T08:30:00Z"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": 1,
    "campaignId": 1,
    "frequencyType": "Once",
    "runQuantity": 1,
    "startAtUtc": "2026-11-11T08:30:00Z",
    "endAtUtc": "2026-11-11T08:30:00Z",
    "nextRunAtUtc": "2026-11-11T08:30:00Z",
    "isActive": true
  },
  "error": null
}

20.12 Payment gateway: merchant and checkout API

GET /api/checkout-sessions

What it does: The merchant's sessions for an orderId, newest first.

See: §12.12 · §13.1

Auth: Merchant API key (sk_test_/sk_live_), rate limit MerchantApi

Query: orderId (string?)

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "ref": "ZP-mPpYuD3HOSFRvgJjSr48gMYRkZniTiIm",
      "status": "Completed",
      "mode": "…",
      "orderId": "…",
      "amountCents": 2900,
      "currency": "ZAR",
      "description": "…",
      "noTip": true,
      "expiresAt": "…",
      "checkoutUrl": "…",
      "paidAmountCents": 2900,
      "tipCents": 2900,
      "feeCents": 2900,
      "netCents": 2900,
      "reconciliation": "…",
      "reviewFlag": "…",
      "paymentMethod": "wallet",
      "metadata": "…",
      "createdAt": "…",
      "refundedAmountCents": 2900
    }
  ],
  "error": null
}

POST /api/checkout-sessions

What it does: Creates a checkout session for an order (idempotent on orderId and Idempotency-Key) and returns the signed checkoutUrl (13.1).

See: §12.12 · §13.1

Auth: Merchant API key (sk_test_/sk_live_), rate limit MerchantApi

Request (CreateCheckoutSessionRequest):

{
  "orderId": "…",
  "amountCents": 2900,
  "currency": "ZAR",
  "description": "…",
  "noTip": true,
  "paymentMethod": "wallet",
  "successUrl": "…",
  "cancelUrl": "…",
  "notifyUrl": "…",
  "metadata": "…",
  "expiresInMinutes": 1
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "ref": "ZP-mPpYuD3HOSFRvgJjSr48gMYRkZniTiIm",
    "status": "Completed",
    "mode": "…",
    "orderId": "…",
    "amountCents": 2900,
    "currency": "ZAR",
    "description": "…",
    "noTip": true,
    "expiresAt": "…",
    "checkoutUrl": "…",
    "paidAmountCents": 2900,
    "tipCents": 2900,
    "feeCents": 2900,
    "netCents": 2900,
    "reconciliation": "…",
    "reviewFlag": "…",
    "paymentMethod": "wallet",
    "metadata": "…",
    "createdAt": "…",
    "refundedAmountCents": 2900
  },
  "error": null
}

GET /api/checkout-sessions/{reference}/refunds

What it does: Refunds of one of the merchant's sessions.

See: §12.12 · §13.4 · §13.5

Auth: Merchant API key (sk_test_/sk_live_), rate limit MerchantApi

Route: reference

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "id": "rf_DQC2802MZPW5PANJ1NAFC58T",
      "ref": "ZP-…",
      "amountCents": 5000,
      "status": "PENDING",
      "method": "eft_corp",
      "isDuplicate": false,
      "reason": "One item returned",
      "failureReason": null,
      "createdAt": "2026-11-11T08:30:00Z",
      "completedAt": null
    }
  ],
  "error": null
}

POST /api/checkout-sessions/{reference}/refunds

What it does: Refunds a paid session in full or in part (wallet refunds complete at once; bank refunds wait for EFT Corp). Idempotency-Key supported (13.4–13.5).

See: §12.12 · §13.4 · §13.5

Auth: Merchant API key (sk_test_/sk_live_), rate limit MerchantApi

Route: reference

Request (CreateRefundRequest):

{
  "amountCents": 2900,
  "reason": null
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": "rf_DQC2802MZPW5PANJ1NAFC58T",
    "ref": "ZP-…",
    "amountCents": 5000,
    "status": "PENDING",
    "method": "eft_corp",
    "isDuplicate": false,
    "reason": "One item returned",
    "failureReason": null,
    "createdAt": "2026-11-11T08:30:00Z",
    "completedAt": null
  },
  "error": null
}

GET /api/checkout-sessions/{ref}

What it does: One of the merchant's sessions (status, amounts, fee, reconciliation, refunded amount).

See: §12.12 · §13.1

Auth: Merchant API key (sk_test_/sk_live_), rate limit MerchantApi

Route: ref

Response 200:

{
  "isSuccess": true,
  "data": {
    "ref": "ZP-mPpYuD3HOSFRvgJjSr48gMYRkZniTiIm",
    "status": "Completed",
    "mode": "…",
    "orderId": "…",
    "amountCents": 2900,
    "currency": "ZAR",
    "description": "…",
    "noTip": true,
    "expiresAt": "…",
    "checkoutUrl": "…",
    "paidAmountCents": 2900,
    "tipCents": 2900,
    "feeCents": 2900,
    "netCents": 2900,
    "reconciliation": "…",
    "reviewFlag": "…",
    "paymentMethod": "wallet",
    "metadata": "…",
    "createdAt": "…",
    "refundedAmountCents": 2900
  },
  "error": null
}

POST /api/checkout-sessions/{ref}/expire

What it does: Cancels an unpaid (CREATED) session; sends payment.cancelled.

See: §12.12 · §13.1

Auth: Merchant API key (sk_test_/sk_live_), rate limit MerchantApi

Route: ref

Response 200:

{
  "isSuccess": true,
  "data": {
    "ref": "ZP-mPpYuD3HOSFRvgJjSr48gMYRkZniTiIm",
    "status": "Completed",
    "mode": "…",
    "orderId": "…",
    "amountCents": 2900,
    "currency": "ZAR",
    "description": "…",
    "noTip": true,
    "expiresAt": "…",
    "checkoutUrl": "…",
    "paidAmountCents": 2900,
    "tipCents": 2900,
    "feeCents": 2900,
    "netCents": 2900,
    "reconciliation": "…",
    "reviewFlag": "…",
    "paymentMethod": "wallet",
    "metadata": "…",
    "createdAt": "…",
    "refundedAmountCents": 2900
  },
  "error": null
}

GET /api/checkout-sessions/{ref}/return

What it does: Checkout page: where to send the buyer after paying (success or cancel URL with status).

See: §12.12 · §13.1

Auth: None (checkout portal; public rate limiter; portal CORS)

Route: ref

Query: outcome (string?)

Response 200:

{
  "isSuccess": true,
  "data": {
    "url": "https://example.com",
    "merchantName": "Thandi"
  },
  "error": null
}

GET /api/checkout-sessions/{ref}/verify

What it does: Checkout page: verifies the signed link and returns what to display. Every failure is the same 404.

See: §12.12 · §13.1

Auth: None (checkout portal; public rate limiter; portal CORS)

Route: ref

Query: key (string?), zakhaaId (string?), amountCents (string?)

Response 200:

{
  "isSuccess": true,
  "data": {
    "valid": true,
    "amountCents": 2900,
    "noTip": true,
    "expiresAt": "…",
    "merchantName": "Thandi",
    "zakhaaId": "…"
  },
  "error": null
}

POST /api/webhooks/validate

What it does: Confirms Zakhaa really sent a given webhook payload to this merchant.

See: §12.12 · §13.1

Auth: Merchant API key (sk_test_/sk_live_), rate limit MerchantApi

Request (ValidateWebhookRequest):

{
  "eventId": "…",
  "payload": "…"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "valid": true
  },
  "error": null
}

20.13 Payment gateway: admin

GET /api/gateway/admin/checkout-sessions/{reference}

What it does: Full session detail for ops: payments received, refunds, review history, webhook events.

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: reference

Response 200:

{
  "isSuccess": true,
  "data": { "session": …, "merchant": …, "review": …, "state": …, "resolvedAt": …, "resolvedBy": …, "resolution": …, "history": …, "action": …, "reviewFlag": …, "resolution": …, "note": …, "by": …, "webhookEvents": …, "payments": …, "refunds": … },
  "error": null
}

GET /api/gateway/admin/fee-plans

What it does: Gateway fee plans with their fee rows and how many merchants use each.

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "id": "…",
      "name": "Thandi",
      "flatCents": 2900,
      "percent": 1.00,
      "active": true,
      "editable": true,
      "merchantCount": 1,
      "rows": [{
        "id": 1,
        "code": "<code>",
        "type": "Payment",
        "amount": 150.00,
        "pct": true,
        "status": true,
        "updatedAt": "2026-11-11T08:30:00Z"
      }]
    }
  ],
  "error": null
}

POST /api/gateway/admin/fee-plans

What it does: Creates a fee plan (flat cents and/or percent).

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Request (CreateFeePlanRequest):

{
  "id": "…",
  "name": "Thandi",
  "flatCents": 2900,
  "percent": 1.00
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": "GW_STANDARD",
    "name": "Standard",
    "flatCents": 150,
    "percent": 1.5,
    "active": true,
    "editable": true,
    "merchantCount": 0,
    "rows": [ { "id": 41, "code": "GW_STANDARD_FLAT", "type": "Gateway", "amount": 1.50, "pct": false, "status": true, "updatedAt": "…" } ]
  },
  "error": null
}

GET /api/gateway/admin/fee-plans/{id}

What it does: One fee plan and the merchants on it.

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: id

Response 200:

{
  "isSuccess": true,
  "data": { "plan": …, "merchants": … },
  "error": null
}

PATCH /api/gateway/admin/fee-plans/{id}

What it does: Edits a fee plan (name, amounts, active).

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: id

Request (UpdateFeePlanRequest):

{
  "name": "Thandi",
  "flatCents": 2900,
  "percent": 1.00,
  "active": true
}

Response 200:

{
  "isSuccess": true,
  "data": { "id": "GW_STANDARD", "name": "Standard", "flatCents": 150, "percent": 1.5, "active": true, "editable": true, "merchantCount": 12, "rows": [ … ] },
  "error": null
}

GET /api/gateway/admin/merchants

What it does: Merchants, searchable and paged.

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Query: search (string?), status (string?), page (int?), pageSize (int?)

Response 200:

{
  "isSuccess": true,
  "data": {
    "items": [
      {
        "id": …,
        "legalName": …,
        "tradingName": …,
        "kybStatus": …,
        "walletId": …,
        "status": …,
        "feePlanId": …,
        "minAmountCents": …,
        "maxAmountCents": …,
        "defaultNotifyUrl": …,
        "defaultSuccessUrl": …,
        "defaultCancelUrl": …,
        "defaultNoTip": …,
        "notificationEmail": …,
        "createdAt": …
      }
    ],
    "totalRecords": 1,
    "totalPages": 1,
    "currentPage": 1,
    "pageSize": 20,
    "hasNextPage": false,
    "hasPreviousPage": false
  },
  "error": null
}

POST /api/gateway/admin/merchants

What it does: Onboards a merchant (starts in sandbox) and returns its first signing secret (shown once).

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Request (CreateMerchantRequest):

{
  "legalName": "Thandi",
  "tradingName": "Thandi",
  "walletId": "9471452626",
  "kybStatus": "Completed",
  "feePlanId": "…",
  "minAmountCents": 2900,
  "maxAmountCents": 2900,
  "defaultNotifyUrl": "…",
  "defaultSuccessUrl": "…",
  "defaultCancelUrl": "…",
  "defaultNoTip": true,
  "notificationEmail": "thandi@example.com",
  "domains": ["…"]
}

Response 200:

{
  "isSuccess": true,
  "data": { "merchant": …, "signingSecret": … },
  "error": null
}

GET /api/gateway/admin/merchants/{id}

What it does: Merchant detail with domains and API keys (prefixes only).

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: id

Response 200:

{
  "isSuccess": true,
  "data": { "merchant": …, "domains": …, "apiKeys": … },
  "error": null
}

PATCH /api/gateway/admin/merchants/{id}

What it does: Edits a merchant: status (sandbox/live/suspended), KYB, fee plan, defaults.

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: id

Request (UpdateMerchantRequest):

{
  "tradingName": "Thandi",
  "kybStatus": "Completed",
  "status": "Completed",
  "feePlanId": "…",
  "minAmountCents": 2900,
  "maxAmountCents": 2900,
  "defaultNotifyUrl": "…",
  "defaultSuccessUrl": "…",
  "defaultCancelUrl": "…",
  "defaultNoTip": true,
  "notificationEmail": "thandi@example.com"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": …,
    "legalName": …,
    "tradingName": …,
    "kybStatus": …,
    "walletId": …,
    "status": …,
    "feePlanId": …,
    "minAmountCents": …,
    "maxAmountCents": …,
    "defaultNotifyUrl": …,
    "defaultSuccessUrl": …,
    "defaultCancelUrl": …,
    "defaultNoTip": …,
    "notificationEmail": …,
    "createdAt": …
  },
  "error": null
}

POST /api/gateway/admin/merchants/{id}/api-keys

What it does: Issues a test or live API key (shown once; live needs a live merchant).

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: id

Request (CreateApiKeyRequest):

{
  "mode": "…"
}

Response 200:

{
  "isSuccess": true,
  "data": { "id": …, "prefix": …, "mode": …, "apiKey": … },
  "error": null
}

DELETE /api/gateway/admin/merchants/{id}/api-keys/{keyId}

What it does: Revokes an API key.

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: id, keyId

Response 200:

{
  "isSuccess": true,
  "data": { … },
  "error": null
}

GET /api/gateway/admin/merchants/{id}/checkout-sessions

What it does: A merchant's sessions, filterable and paged.

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: id

Query: ref (string?), orderId (string?), status (string?), mode (string?), from (DateTime?), to (DateTime?), page (int?), pageSize (int?)

Response 200:

{
  "isSuccess": true,
  "data": {
    "items": [
      {
        "ref": …,
        "merchantId": …,
        "mode": …,
        "orderId": …,
        "status": …,
        "amountCents": …,
        "paidAmountCents": …,
        "tipCents": …,
        "feeCents": …,
        "netCents": …,
        "reconciliation": …,
        "reviewFlag": …,
        "reviewResolvedAt": …,
        "reviewResolution": …,
        "paymentMethod": …,
        "processorPaymentId": …,
        "expiresAt": …,
        "createdAt": …,
        "updatedAt": …
      }
    ],
    "totalRecords": 1,
    "totalPages": 1,
    "currentPage": 1,
    "pageSize": 20,
    "hasNextPage": false,
    "hasPreviousPage": false
  },
  "error": null
}

POST /api/gateway/admin/merchants/{id}/domains

What it does: Adds a domain the merchant's return URLs may use.

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: id

Request (AddDomainRequest):

{
  "domain": "…"
}

Response 200:

{
  "isSuccess": true,
  "data": { "domain": … },
  "error": null
}

DELETE /api/gateway/admin/merchants/{id}/domains/{domain}

What it does: Removes a merchant domain.

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: id, domain

Response 200:

{
  "isSuccess": true,
  "data": { … },
  "error": null
}

POST /api/gateway/admin/merchants/{id}/signing-secrets/rotate

What it does: Rotates the webhook signing secret; the old one stays valid for 24 hours.

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: id

Request (RotateSecretRequest):

{
  "overlapHours": 1
}

Response 200:

{
  "isSuccess": true,
  "data": { "signingSecret": …, "previousRetiresInHours": … },
  "error": null
}

GET /api/gateway/admin/merchants/{id}/webhook-events

What it does: The newest webhook events and every delivery attempt.

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: id

Query: ref (string?)

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "id": "evt_…",
      "ref": "ZP-…",
      "type": "payment.succeeded",
      "deliveryState": "DELIVERED",
      "attemptCount": 1,
      "nextAttemptAt": null,
      "deliveredAt": "2026-11-11T08:30:00Z",
      "createdAt": "2026-11-11T08:29:58Z",
      "deliveries": [ { "attempt": 1, "statusCode": 200, "error": null, "durationMs": 142, "createdAt": "…", "deliveredAt": "…" } ]
    }
  ],
  "error": null
}

GET /api/gateway/admin/review-queue

What it does: Sessions flagged for review (late, duplicate, wallet or mode mismatch).

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Query: state (string?), flag (string?), merchantId (string?)

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "ref": …,
      "merchantId": …,
      "mode": …,
      "orderId": …,
      "status": …,
      "reviewFlag": …,
      "amountCents": …,
      "paidAmountCents": …,
      "tipCents": …,
      "reconciliation": …,
      "processorPaymentId": …,
      "updatedAt": …,
      "noteCount": …,
      "reviewResolvedAt": …,
      "reviewResolvedBy": …,
      "reviewResolution": …
    }
  ],
  "error": null
}

GET /api/gateway/admin/review-queue/count

What it does: Number of open review items (for a badge).

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Response 200:

{
  "isSuccess": true,
  "data": { "open": …, "byFlag": …, "count": … },
  "error": null
}

POST /api/gateway/admin/review-queue/{reference}/notes

What it does: Adds a note to a review item.

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: reference

Request (ReviewNoteRequest):

{
  "note": "Lunch"
}

Response 200:

{
  "isSuccess": true,
  "data": { "id": …, "action": …, "reviewFlag": …, "note": …, "adminId": …, "createdAt": … },
  "error": null
}

POST /api/gateway/admin/review-queue/{reference}/reopen

What it does: Reopens a resolved review item.

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: reference

Request (ReviewNoteRequest):

{
  "note": "Lunch"
}

Response 200:

{
  "isSuccess": true,
  "data": { "ref": …, "reviewFlag": …, "state": … },
  "error": null
}

POST /api/gateway/admin/review-queue/{reference}/resolve

What it does: Resolves a review item with a resolution and note.

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: reference

Request (ResolveReviewRequest):

{
  "resolution": "…",
  "note": "Lunch"
}

Response 200:

{
  "isSuccess": true,
  "data": { "ref": …, "reviewFlag": …, "reviewResolvedAt": …, "reviewResolvedBy": …, "reviewResolution": … },
  "error": null
}

POST /api/gateway/admin/sandbox/sessions/{ref}/callback

What it does: Simulates a payment result on a test-mode session (no money moves).

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: ref

Request (SandboxCallbackRequest):

{
  "status": "Completed",
  "paidAmountCents": 2900,
  "creditedWalletId": "9471452626"
}

Response 200:

{
  "isSuccess": true,
  "data": { },
  "error": null
}

POST /api/gateway/admin/webhook-events/{eventId}/resend

What it does: Re-queues a webhook with the same payload (freshly signed).

See: §12.13 · §13.1 · §19.3

Auth: Roles: SuperAdmin, Admin

Route: eventId

Response 200:

{
  "isSuccess": true,
  "data": { … },
  "error": null
}

20.14 Refunds (admin)

GET /api/admin/refunds

What it does: Refunds, filterable (ref, wallet, payment, status) and sortable, paged.

See: §12.14 · §13.4 · §13.5 · §19.2

Auth: Roles: SuperAdmin, Admin

Query: ref (string?), walletId (string?), topupId (int?), status (string?), sort (string?), page (int?), pageSize (int?)

Response 200:

{
  "isSuccess": true,
  "data": {
    "items": [
      {
        "id": …,
        "topupId": …,
        "ref": …,
        "merchantId": …,
        "mode": …,
        "isSessionPayment": …,
        "isDuplicate": …,
        "amountCents": …,
        "status": …,
        "method": …,
        "bankOperation": …,
        "reason": …,
        "failureReason": …,
        "requestedByType": …,
        "requestedBy": …,
        "requestedByName": …,
        "receiverWalletId": …,
        "receiverWithdrawalId": …,
        "payerWalletId": …,
        "payerTopupId": …,
        "processorTransactionId": …,
        "processorRefundId": …,
        "settledBy": …,
        "settledByName": …,
        "settlementNote": …,
        "createdAt": …,
        "completedAt": …
      }
    ],
    "totalRecords": 1,
    "totalPages": 1,
    "currentPage": 1,
    "pageSize": 20,
    "hasNextPage": false,
    "hasPreviousPage": false
  },
  "error": null
}

POST /api/admin/refunds

What it does: Refunds any incoming payment (checkout or ordinary Send Zakhaa / bank payment), in full or in part. Audited.

See: §12.14 · §13.4 · §13.5 · §19.2

Auth: Roles: SuperAdmin, Admin

Request (AdminRefundRequest):

{
  "amountCents": 2900,
  "topupId": 1,
  "ref": "ZP-mPpYuD3HOSFRvgJjSr48gMYRkZniTiIm",
  "reason": null
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": …,
    "topupId": …,
    "ref": …,
    "merchantId": …,
    "mode": …,
    "isSessionPayment": …,
    "isDuplicate": …,
    "amountCents": …,
    "status": …,
    "method": …,
    "bankOperation": …,
    "reason": …,
    "failureReason": …,
    "requestedByType": …,
    "requestedBy": …,
    "requestedByName": …,
    "receiverWalletId": …,
    "receiverWithdrawalId": …,
    "payerWalletId": …,
    "payerTopupId": …,
    "processorTransactionId": …,
    "processorRefundId": …,
    "settledBy": …,
    "settledByName": …,
    "settlementNote": …,
    "createdAt": …,
    "completedAt": …
  },
  "error": null
}

GET /api/admin/refunds/{id}

What it does: One refund with who requested and settled it.

See: §12.14 · §13.4 · §13.5 · §19.2

Auth: Roles: SuperAdmin, Admin

Route: id

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": …,
    "topupId": …,
    "ref": …,
    "merchantId": …,
    "mode": …,
    "isSessionPayment": …,
    "isDuplicate": …,
    "amountCents": …,
    "status": …,
    "method": …,
    "bankOperation": …,
    "reason": …,
    "failureReason": …,
    "requestedByType": …,
    "requestedBy": …,
    "requestedByName": …,
    "receiverWalletId": …,
    "receiverWithdrawalId": …,
    "payerWalletId": …,
    "payerTopupId": …,
    "processorTransactionId": …,
    "processorRefundId": …,
    "settledBy": …,
    "settledByName": …,
    "settlementNote": …,
    "createdAt": …,
    "completedAt": …
  },
  "error": null
}

POST /api/admin/refunds/{id}/check

What it does: Asks EFT Corp about a pending bank refund and applies the answer.

See: §12.14 · §13.4 · §13.5 · §19.2

Auth: Roles: SuperAdmin, Admin

Route: id

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": …,
    "topupId": …,
    "ref": …,
    "merchantId": …,
    "mode": …,
    "isSessionPayment": …,
    "isDuplicate": …,
    "amountCents": …,
    "status": …,
    "method": …,
    "bankOperation": …,
    "reason": …,
    "failureReason": …,
    "requestedByType": …,
    "requestedBy": …,
    "requestedByName": …,
    "receiverWalletId": …,
    "receiverWithdrawalId": …,
    "payerWalletId": …,
    "payerTopupId": …,
    "processorTransactionId": …,
    "processorRefundId": …,
    "settledBy": …,
    "settledByName": …,
    "settlementNote": …,
    "createdAt": …,
    "completedAt": …
  },
  "error": null
}

POST /api/admin/refunds/{id}/settle

What it does: Manually settles a pending bank refund as succeeded or failed (note required). Audited.

See: §12.14 · §13.4 · §13.5 · §19.2

Auth: Roles: SuperAdmin, Admin

Route: id

Request (SettleRefundRequest):

{
  "outcome": "…",
  "note": "Lunch"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "id": …,
    "topupId": …,
    "ref": …,
    "merchantId": …,
    "mode": …,
    "isSessionPayment": …,
    "isDuplicate": …,
    "amountCents": …,
    "status": …,
    "method": …,
    "bankOperation": …,
    "reason": …,
    "failureReason": …,
    "requestedByType": …,
    "requestedBy": …,
    "requestedByName": …,
    "receiverWalletId": …,
    "receiverWithdrawalId": …,
    "payerWalletId": …,
    "payerTopupId": …,
    "processorTransactionId": …,
    "processorRefundId": …,
    "settledBy": …,
    "settledByName": …,
    "settlementNote": …,
    "createdAt": …,
    "completedAt": …
  },
  "error": null
}

20.15 Cash devices: partner API

POST /api/cash-devices/transactions

What it does: A cash device reports a deposit (1), collection (2) or OTP-confirmed withdrawal (3). Idempotent per device + reference; credits or debits the wallet with the cash-device fee (13.10).

See: §12.15 · §13.10

Auth: Partner token, scope zcashdeposit (+ token revocation check)

Request (CashDeviceTransactionRequest):

{
  "requestMessage": {
    "transactionReference": 1,
    "userId": 1,
    "customerReference": "0821234567",
    "deviceSerial": 1,
    "serverDateTime": "…",
    "localDateTime": "…",
    "barcode": "<code>",
    "transactionType": "1",
    "walletId": "9471452626",
    "withdrawalRequestId": "…",
    "otp": "482913",
    "currency": "ZAR",
    "totalValue": "500.00",
    "denominations": [ { … } ]
  }
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "zakhaaReference": "ZCD-20261111-000123",
    "transactionReference": 1,
    "deviceSerial": 1,
    "transactionType": "1",
    "status": "Completed",
    "walletId": "9471452626",
    "amountCredited": "…",
    "amountDebited": "…",
    "currency": "ZAR",
    "processedAt": "2026-11-11T08:30:00Z"
  },
  "error": null
}

GET /api/cash-devices/transactions/{deviceSerial}/{transactionReference}

What it does: Status of a transaction the device submitted.

See: §12.15 · §13.10

Auth: Partner token, scope zcashdeposit (+ token revocation check)

Route: deviceSerial, transactionReference

Response 200:

{
  "isSuccess": true,
  "data": {
    "zakhaaReference": "ZCD-20261111-000123",
    "transactionReference": 1,
    "deviceSerial": 1,
    "transactionType": "1",
    "status": "Completed",
    "walletId": "9471452626",
    "amountCredited": "…",
    "amountDebited": "…",
    "currency": "ZAR",
    "processedAt": "2026-11-11T08:30:00Z"
  },
  "error": null
}

GET /api/cash-devices/wallets/{walletId}

What it does: Confirms a wallet exists and is active before a deposit or withdrawal.

See: §12.15 · §13.10

Auth: Partner token, scope zcashdeposit (+ token revocation check)

Route: walletId

Response 200:

{
  "isSuccess": true,
  "data": {
    "walletId": "9471452626",
    "displayName": "Thandi",
    "isActive": true,
    "currency": "ZAR"
  },
  "error": null
}

POST /api/cash-devices/withdrawals/request

What it does: Starts a cash withdrawal: checks the balance and sends a one-time code (5 minutes, 3 attempts) to the customer's app.

See: §12.15 · §13.10

Auth: Partner token, scope zcashdeposit (+ token revocation check)

Request (WithdrawalRequestDto):

{
  "requestReference": 1,
  "deviceSerial": 1,
  "userId": 1,
  "walletId": "9471452626",
  "currency": "ZAR",
  "amount": "…"
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "withdrawalRequestId": "…",
    "walletId": "9471452626",
    "displayName": "Thandi",
    "amount": "…",
    "currency": "ZAR",
    "status": "Completed",
    "otpExpiresAt": "2026-11-11T08:30:00Z"
  },
  "error": null
}

POST /api/cash-devices/withdrawals/reverse

What it does: Refunds a withdrawal the device couldn't dispense (within 24 hours, once).

See: §12.15 · §13.10

Auth: Partner token, scope zcashdeposit (+ token revocation check)

Request (WithdrawalReversalRequest):

{
  "deviceSerial": 1,
  "originalTransactionReference": 1,
  "reason": null
}

Response 200:

{
  "isSuccess": true,
  "data": {
    "zakhaaReference": "ZCD-20261111-000123",
    "transactionReference": 1,
    "deviceSerial": 1,
    "transactionType": "1",
    "status": "Completed",
    "walletId": "9471452626",
    "amountCredited": "…",
    "amountDebited": "…",
    "currency": "ZAR",
    "processedAt": "2026-11-11T08:30:00Z"
  },
  "error": null
}

20.16 Cash devices: admin

GET /api/admin/cash-devices/collections

What it does: Cash collections with expected amount and variance, paged.

See: §12.15 · §13.10

Auth: Roles: SuperAdmin, Admin

Query: clientId (string?), deviceSerial (int?), varianceOnly (bool?), from (DateTime?), to (DateTime?), page (int?), pageSize (int?)

Response 200:

{
  "isSuccess": true,
  "data": { "summary": …, "collectedCents": …, "expectedCents": …, "shortfallCents": …, "surplusCents": …, "items": …, "totalRecords": …, "totalPages": …, "currentPage": …, "pageSize": …, "hasNextPage": …, "hasPreviousPage": … },
  "error": null
}

GET /api/admin/cash-devices/devices

What it does: Every device with its totals and the cash it should hold now.

See: §12.15 · §13.10

Auth: Roles: SuperAdmin, Admin

Query: clientId (string?)

Response 200:

{
  "isSuccess": true,
  "data": [
    {
      "clientId": …,
      "deviceSerial": …,
      "firstSeenAt": …,
      "lastSeenAt": …,
      "depositCount": …,
      "depositTotalCents": …,
      "withdrawalCount": …,
      "withdrawalTotalCents": …,
      "reversalCount": …,
      "collectionCount": …,
      "lastCollectionAt": …,
      "lastCollectionVarianceCents": …,
      "expectedCashCents": …
    }
  ],
  "error": null
}

GET /api/admin/cash-devices/reversals

What it does: Reversed withdrawals, paged.

See: §12.15 · §13.10

Auth: Roles: SuperAdmin, Admin

Query: clientId (string?), deviceSerial (int?), walletId (string?), from (DateTime?), to (DateTime?), page (int?), pageSize (int?)

Response 200:

{
  "isSuccess": true,
  "data": {
    "items": [
      {
        "zakhaaReference": …,
        "clientId": …,
        "deviceSerial": …,
        "walletId": …,
        "amountReversedCents": …,
        "reversalReason": …,
        "reversedAt": …
      }
    ],
    "totalRecords": 1,
    "totalPages": 1,
    "currentPage": 1,
    "pageSize": 20,
    "hasNextPage": false,
    "hasPreviousPage": false
  },
  "error": null
}

GET /api/admin/cash-devices/transactions

What it does: Cash-device transactions, filterable and paged.

See: §12.15 · §13.10

Auth: Roles: SuperAdmin, Admin

Query: clientId (string?), deviceSerial (int?), type (string?), status (string?), walletId (string?), reference (string?), from (DateTime?), to (DateTime?), page (int?), pageSize (int?)

Response 200:

{
  "isSuccess": true,
  "data": {
    "items": [
      {
        "zakhaaReference": …,
        "clientId": …,
        "deviceSerial": …,
        "transactionReference": …,
        "type": …,
        "status": …,
        "walletId": …,
        "customerReference": …,
        "currency": …,
        "totalValueCents": …,
        "amountCreditedCents": …,
        "amountDebitedCents": …,
        "feeCents": …,
        "vatCents": …,
        "withdrawalRequestId": …,
        "expectedValueCents": …,
        "varianceCents": …,
        "reversedAt": …,
        "amountReversedCents": …,
        "reversalReason": …,
        "localDateTime": …,
        "processedAt": …
      }
    ],
    "totalRecords": 1,
    "totalPages": 1,
    "currentPage": 1,
    "pageSize": 20,
    "hasNextPage": false,
    "hasPreviousPage": false
  },
  "error": null
}

GET /api/admin/cash-devices/transactions/{zakhaaReference}

What it does: One cash-device transaction with its denominations and linked withdrawal request.

See: §12.15 · §13.10

Auth: Roles: SuperAdmin, Admin

Route: zakhaaReference

Response 200:

{
  "isSuccess": true,
  "data": { "transaction": …, "barcode": …, "paySolutionUserId": …, "serverDateTime": …, "denominations": …, "withdrawalRequest": … },
  "error": null
}

GET /api/admin/cash-devices/withdrawal-requests

What it does: Withdrawal requests (OTPs never returned), paged.

See: §12.15 · §13.10

Auth: Roles: SuperAdmin, Admin

Query: clientId (string?), deviceSerial (int?), status (string?), walletId (string?), from (DateTime?), to (DateTime?), page (int?), pageSize (int?)

Response 200:

{
  "isSuccess": true,
  "data": {
    "items": [
      {
        "withdrawalRequestId": …,
        "clientId": …,
        "deviceSerial": …,
        "walletId": …,
        "amountCents": …,
        "status": …,
        "isStale": …,
        "failedAttempts": …,
        "createdAt": …,
        "expiresAt": …,
        "closedAt": …,
        "transactionZakhaaReference": …
      }
    ],
    "totalRecords": 1,
    "totalPages": 1,
    "currentPage": 1,
    "pageSize": 20,
    "hasNextPage": false,
    "hasPreviousPage": false
  },
  "error": null
}

21. Services and components

Every service, repository, background job, helper and piece of plumbing, grouped by what it does. For each: its responsibility, file, main public methods, what it depends on (constructor), what uses it, and where the handbook covers it in more detail. Dependencies, methods and callers are taken from the code (callers are other classes that reference it; DI registration files are excluded).

21.1 Money movement and the ledger

DbWalletLedger

The engine behind every rail that calls an outside provider (VAS, PayShap, Zapper, Scan to Pay, CashSend). ReserveAsync locks the wallet, runs the checks in order (frozen → unverified ceiling → trust-tier cap → daily → monthly → spendable balance with holds), calls the fraud model, debits with a ledger row and creates the pending rail record. FinalizeAsync marks it completed; RefundAsync credits it back (*_refund). The CashSend partial adds FinalizeCashSendAsync, RefundCashSendAsync, MarkRedeemedAsync.

File Services/Ledger/DbWalletLedger.cs
Main methods ReserveAsync, FinalizeAsync, RefundAsync
Depends on AppDBContext, FraudPredictionService, FraudFeatureBuilder
Used by CashDeviceRules, CashSendRepository, DbWalletLedger.CashSend, PayshapRepository, PayshapStatusBackgroundService, ScanToPayRepository, ScanToPayStatusBackgroundService, VasService (+1 more)
See §7.4 · §7.7 · §13.9 · §13.13 · §13.15

WithdrawalRepository

Send Zakhaa. CreateAsync runs the whole transfer in one serializable transaction: locks both wallets, checks limits/tier/holds, applies the fee and VAT, writes the sender's Withdrawal + debit ledger row, saves the beneficiary, credits the receiver (or pays a checkout order), commits, then notifies both sides.

File Repositories/WithdrawalRepository.cs
Main methods CreateAsync, DeleteAsync, GetByIdAsync, GetByWalletIdAsync, UpdateAsync
Depends on AppDBContext, ITopUpRepository, IBeneficiaryRepository, INotificationRepository, IWalletRepository, IFeeRepository, NotificationService, CheckoutPaymentService
Used by WithdrawalController
See §13.8 · §13.2

TopUpRepository

Credits (money in). CreateTopUpAsync(ctx, withdrawal, wallet) writes the receiver's side of a Send Zakhaa or voucher redemption (inside the caller's transaction); CreateTopUpAsync(topup) is the standalone manual credit with its own lock, duplicate check, ledger row and notification.

File Repositories/TopUpRepository.cs
Main methods CreateTopUpAsync, DeleteByIdAsync, GetByIdAsync, GetbyWalletIdAsync, UpdateTopUpAsync
Depends on AppDBContext, IWalletRepository, NotificationService, INotificationRepository
Used by TopUpController, WithdrawalRepository, ZakhaaVoucherRepository
See §13.11

FeeRepository

Fee calculation: ApplyFee looks up a fee code in Fees (percentage or fixed), adds 15% VAT and stores both on the withdrawal or voucher.

File Repositories/FeeRepository.cs
Main methods ApplyFee
Depends on AppDBContext
Used by WithdrawalRepository, ZakhaaVoucherRepository
See §7.3

WalletRepository

Wallet creation (takes the next free ID from WalletIDs), lookups, and CheckLimits (unverified, daily, monthly limits from TransactionLimit).

File Repositories/WalletRepository.cs
Main methods CreateAsync, DeleteAsync, GetByUserIDAsync, GetByIdAsync, UpdateAsync, ValidateWallet, ValidateWallets, CheckLimits
Depends on AppDBContext
Used by AuthRepository, BusinessRepository, TopUpRepository, TransferRepository, WalletController, WithdrawalRepository, ZakhaaVoucherRepository
See §13.22 · §7.4

WalletLedgerRepository

Small helpers over WalletLedgers (add, list by wallet, last entry).

File Repositories/WalletLedgerRepository.cs
Main methods AddAsync, GetByWalletIdAsync, GetLastEntryAsync, SaveChangesAsync
Depends on AppDBContext
See §7.1

TransactionRepository

Builds a wallet's history by combining Topups, Withdrawals and the VAS, PayShap, Zapper, Scan to Pay and CashSend tables, newest first. This is what the app shows.

File Repositories/TransactionRepository.cs
Main methods GetTransactionsAsync
Depends on AppDBContext
Used by AuthRepository, UserController
See §7.1

ZakhaaVoucherRepository

Zakhaa vouchers: create (limits, fee, 12-digit number, 1-year expiry), redeem (credit the target wallet), reverse (refund the creator) and expire.

File Repositories/ZakhaaVoucherRepository.cs
Main methods CreateAsync, DeleteAsync, GetByIdAsync, GetVoucherNumberAsync, GetByWalletIdAsync, RedeemAsync, ReverseAsync, SetExpiredAsync
Depends on AppDBContext, ITopUpRepository, INotificationRepository, IWalletRepository, IFeeRepository
Used by AuthRepository, ZakhaaVoucherController
See §13.12

BeneficiaryRepository

Saves Send Zakhaa recipients automatically (per sender wallet) and reads saved Send Zakhaa and PayShap beneficiaries.

File Repositories/BeneficiaryRepository.cs
Main methods CreateBeneficiaryAsync, GetByWalletIdAsync, GetPayshapByWalletIdAsync
Depends on AppDBContext
Used by PayshapController, WithdrawalController, WithdrawalRepository
See §13.8 · §13.14

TransferRepository

Legacy transfer records (CRUD). Not used by Send Zakhaa, which uses WithdrawalRepository.

File Repositories/TransferRepository.cs
Main methods CreateAsync, DeleteAsync, GetByIdAsync, GetByReceiverIDAsync, GetBySenderIDAsync, UpdateAsync
Depends on AppDBContext, IWalletRepository

PayshapRepository

PayShap payments: eligibility, then reserve → middleware payment → finalise or refund, saving the bank beneficiary.

File Repositories/PayshapRepository.cs
Main methods GetEligibilityAsync, ExecuteAsync
Depends on AppDBContext, IWalletLedger, PayshapService, INotificationRepository
Used by PayshapController
See §13.14

ZapperRepository

Zapper payments: reserve → notify Zapper → finalise, or refund and tell Zapper it failed.

File Repositories/ZapperRepository.cs
Main methods ExecuteAsync
Depends on AppDBContext, IWalletLedger, ZapperService, INotificationRepository
Used by ZapperController
See §13.15

ScanToPayRepository

Paying a scanned Scan to Pay QR from the wallet: reserve amount + tip, finalise with the EFT Corp middleware (polling while pending), finalise or refund. CheckBalanceAndTopUp tops up Zakhaa's EFT Corp float when it drops to R2,000.

File Repositories/ScanToPayRepository.cs
Main methods ExecuteAsync, CheckBalanceAndTopUp
Depends on IMemoryCache, AppDBContext, IWalletLedger, ScanToPayMiddlewareService, INotificationRepository, PayshapService, IBackgroundTaskQueue
Used by ScanToPayController
See §13.15

CashSendRepository

CashSend: initiate (reserve, send to TransMS with a per-transaction callback token), cancel, resend withdrawal number, reset PIN, and the webhook handler (token-checked; refunds on cancellation or rejection, recovery when a failed row turns out to have succeeded).

File Repositories/CashSendRepository.cs
Main methods GetAllAsync, InitiateAsync, CancelAsync, ResendWithdrawalAsync, ResetPinAsync, HandleWebhookAsync
Depends on AppDBContext, IWalletLedger, ICashSendService, INotificationRepository, IConfiguration
Used by CashSendController
See §13.13

VasService

VAS purchases through Flash: validates the operation, reserves funds (every operation except 1Voucher redeem), calls Flash with retries, finalises or refunds. Handles 1Voucher redemption as a credit with its fee, sends receipts, and keeps the Flash float topped up.

File Services/VasService.cs
Main methods ExecuteAsync, CheckBalanceAndTopUp, GetServiceDisplayName, VoucherRedeemedEmailHtml, VoucherRedeemedEmailHtmlFromImportData, Flatten
Depends on IFlashClientService, IWalletLedger, AppDBContext, IEmailSender, INotificationRepository, IMemoryCache, PayshapService, IBackgroundTaskQueue
Used by FlashController
See §13.9

VasRepository

Data helpers for VAS transactions and top-ups (some product-cache methods are not implemented).

File Repositories/VasRepository.cs
Main methods GetWalletByBusIdAsync, GetWalletByIdForUpdateAsync, CreateVasTransactionAsync, UpdateVasTransactionAsync, CreateTopupAsync, SaveChangesAsync, UpsertFlashProductsAsync, GetCachedProductsAsync, GetCachedProductByCodeAsync
Depends on AppDBContext
Used by FlashController
See §13.9

PayshapPolicy

Spendable balance and holds (ComputeSpendableAsync: recent card/EFT deposits held 1–6 hours, doubled if unverified) and PayShap eligibility (ComputeAsync: 2 a day, 30 minutes apart). Lives in Utilities/PayshapEligibility.cs.

File Utilities/PayshapEligibility.cs
Main methods ComputeSpendableAsync, ComputeAsync
Used by AuthRepository, CashDeviceDb, DbWalletLedger, PaymentRefundService, PayshapRepository, WithdrawalRepository, ZakhaaVoucherRepository
See §7.5

TrustTierPolicy

Trust tiers: daily caps (R500 / R1,500 / R5,000 / unlimited), the messages shown when a cap is hit, and ComputeTierAsync (verified, account age, number of outgoing payments).

File Utilities/TrustTierPolicy.cs
Main methods DailyCapForTier, TierMessage, ComputeTierAsync
Used by CashDeviceDb, DbWalletLedger, TrustTierRecomputeJob, WithdrawalRepository, ZakhaaVoucherRepository
See §7.4

FraudFeatureBuilder

Builds the features sent to the fraud model for a payment (amount vs the wallet's average, time since the last transaction, z-score, hour, night, weekend).

File Utilities/FraudFeatureBuilder.cs
Main methods BuildAsync
Depends on AppDBContext
Used by CashDeviceDb, DbWalletLedger
See §7.6

FraudPredictionService

Calls the fraud model (/fraud/predict) and returns the fraud probability. A score of 0.70+ blocks the payment in DbWalletLedger; if the model is down, the payment fails.

File Services/FraudPredictionService.cs
Main methods PredictProbability
Depends on HttpClient
Used by CashDeviceDb, DbWalletLedger
See §7.6

21.2 Payment gateway

CheckoutPaymentService

Everything that happens when a checkout order is paid: deciding whether a card payment may start (server-held amount), the sandbox flow, crediting the merchant with the fee plan and ledger entries, reconciliation (exact/under/over), review flags (late, duplicate, mismatch), Send Zakhaa order payments (PrepareWalletPaymentAsync), and recording failures.

File Services/Gateway/CheckoutPaymentService.cs
Main methods PrepareInitiateAsync, RecordInitiateResultAsync, StartSandboxAsync, SimulateCallbackAsync, FindSessionForCallbackAsync, ComputeMerchantFeeAsync, PrepareWalletPaymentAsync, ApplyWalletSandboxPaymentAsync, ResolveGatewayFeeAsync, AddGatewayLedgerEntriesAsync
Depends on AppDBContext, NotificationService, IBackgroundTaskQueue, IServiceScopeFactory
Used by ScanToPayController, ScanToPayService, SimulateSandboxCallback, StpCallbackProcessor, WithdrawalRepository
See §13.1 · §13.2

PaymentRefundService

Refunds of any incoming payment: picks the payment, reserves the amount, refunds to the payer's wallet at once or asks EFT Corp for a reversal/refund (with a per-refund webhook URL), confirms from EFT Corp's webhooks, admin check and settle.

File Services/Gateway/PaymentRefundService.cs
Main methods RefundAsync, ConfirmFromEftAsync, IsRefundWebhookAuthenticAsync, ConfirmFromRefundWebhookAsync, CheckWithEftAsync, SettleAsync, SessionCreditsAsync
Depends on AppDBContext, IEftCorpClient, INotificationRepository, NotificationService, IConfiguration
Used by AdminRefunds, GetSession, Refunds, ScanToPayController, StpCallbackProcessor
See §13.4 · §13.5

WebhookOutbox

Builds the exact merchant webhook payload once and stores it in webhook_events in the same transaction as the status change. WebhookSchedule holds the retry schedule.

File Services/Gateway/WebhookOutbox.cs
Main methods BuildPayload, Enqueue, TruncateToSeconds
Used by CheckoutExpiryJob, CheckoutPaymentService, CreateCheckoutSession, ExpireCheckoutSession, PaymentRefundService
See §13.1

MerchantSecretStore

Merchant webhook signing secrets: stored encrypted, decrypted only in memory, rotation with a 24-hour overlap.

File Services/Gateway/MerchantSecretStore.cs
Main methods GetValidSecretsAsync, GetActiveSecretAsync, RotateAsync
Depends on AppDBContext, EncryptionService
Used by CreateCheckoutSession, CreateMerchant, GetCheckoutSession, ListCheckoutSessions, RotateSigningSecret, VerifyCheckoutSession, WebhookDeliveryWorker
See §6.7

GatewayCrypto

Gateway identifiers and signatures: ZP-… refs, evt_/rf_/mch_ IDs, API keys and their hashes, signed checkout-link keys, webhook v1 signatures.

File Services/Gateway/GatewayCrypto.cs
Main methods BuildKeyMessage, ComputeKey, KeyEquals, ComputeWebhookV1, BuildSignatureHeader, NewCheckoutRef, NewMerchantId, NewEventId, NewRefundId, NewApiKey
Used by CheckoutPaymentService, CheckoutSessionMapping, CreateApiKey, CreateCheckoutSession, CreateMerchant, MerchantApiKeyAuthenticationHandler, MerchantSecretStore, PaymentRefundService (+3 more)
See §6.7

CheckoutRules

Shared gateway rules and formats: order ID and ref shapes, amount conversion (cents ↔ rand), ISO timestamps, walletId:ref splitting, portal origin.

File Services/Gateway/CheckoutRules.cs
Main methods IsCheckoutRefShape, IsKeyShape, IsValidOrderId, FormatAmount, TryToCents, ToRand, TipCap, TryEvaluateInitiateAmount, Reconcile, ComputeFee
Used by AddDomain, CheckoutPaymentService, CheckoutSessionMapping, CreateCheckoutSession, CreateMerchant, GatewayAdminShared, GetCheckoutReturn, ListCheckoutSessions (+11 more)
See §13.1

NetworkGuard

Protects merchant webhook delivery against SSRF: notify URLs must be public https on port 443, and the resolved address must be public.

File Services/Gateway/NetworkGuard.cs
Main methods IsPublicAddress, IsAcceptableNotifyUrlShape, ResolvePublicAsync, IsSafeNotifyUrlAsync
Used by CreateCheckoutSession, GatewayAdminShared, WebhookDeliveryWorker
See §6.7

CheckoutPublicRateLimiter

Rate limiter for the public checkout page endpoints (verify, return).

File Services/Gateway/CheckoutPublicRateLimiter.cs
Main methods TryAcquire, Dispose
Used by GetCheckoutReturn, VerifyCheckoutSession
See §6.11

CheckoutSessionMapping

Maps a checkout session to the merchant-facing JSON (and decides whether its link is still usable).

File Features/Gateway/CheckoutSessions/CheckoutSessionMapping.cs
Main methods ToDto, LinkIsUsable
Used by CreateCheckoutSession, ExpireCheckoutSession, GetCheckoutSession, ListCheckoutSessions
See §20.12

GatewayAdminShared

Helpers for the gateway admin slices: paging, search patterns, verified domains, default URLs, audit, admin names.

File Features/Gateway/Admin/GatewayAdminShared.cs
Main methods VerifiedDomainsAsync, ApplyDefaultUrlsAsync, AuditAsync, PageAsync, AsUtc, ContainsPattern, AdminNamesAsync, FeePlanCodes, FeePlanIsUsableAsync
Used by CashDeviceAdminApi, CreateFeePlan, CreateMerchant, FeePlanShared, GetReviewQueue, GetSession, ListMerchantSessions, ListMerchants (+3 more)
See §20.13

FeePlanShared

Fee-plan helpers: building a plan's view from its Fees rows, cents/rand conversion, validation.

File Features/Gateway/Admin/FeePlanShared.cs
Main methods BaseId, ToCents, ToRands, ValidateAmounts, PlanView, FeeRowView, LoadAsync, IsStandardLayout
Used by CreateFeePlan, GetFeePlan, ListFeePlans, UpdateFeePlan
See §20.13

ReviewQueueShared

Review-queue helpers: finding flagged sessions, cleaning notes, saving with concurrency handling.

File Features/Gateway/Admin/ReviewQueueShared.cs
Main methods CleanNote, FindFlaggedAsync, TrySaveAsync
Used by AddReviewNote, ReopenReviewItem, ResolveReviewItem
See §20.13

MerchantApi

Merchant API conventions: RequireMerchantApiKey() (auth + rate limit) and Caller() (merchant ID and mode from the key). PortalApi and AdminApi (same file) do the same for the checkout portal and gateway admin.

File Features/Gateway/GatewayApi.cs
Main methods RequireMerchantApiKey, Caller
Used by CreateCheckoutSession, ExpireCheckoutSession, GetCheckoutSession, ListCheckoutSessions, Refunds, ValidateWebhook
See §6.7

StpCallbackProcessor

Processes an EFT Corp payment notification in the background: verifies it with EFT Corp, credits (or pays the checkout order), reverses on permanent failure, confirms refunds and reversals, and records the outcome for the sweep.

File Services/StpCallbackProcessor.cs
Main methods ResolveAsync, RecordOutcomeAsync
Used by ScanToPayController, StpTopUpReconciliationSweep
See §13.3

ScanToPayService

Crediting card/EFT payments (CompletePaymentAsync: idempotent, fee S2P_DEPOSIT or the merchant's fee plan) and clawing back reversed ones (ReverseTransactionAsync).

File Services/ScanToPayService.cs
Main methods CompletePaymentAsync, ReverseTransactionAsync
Depends on AppDBContext, INotificationRepository, CheckoutPaymentService
Used by ScanToPayController, StpCallbackProcessor
See §13.3 · §13.11

EftCorpClient

Direct EFT Corp API: verify a notification (purchase/notification/{id}), reverse (DELETE purchase/{id}), refund (POST purchase/doRefund/{id}). Paths are relative to EftCorp:BaseUrl.

File Services/EftCorpClient.cs
Main methods VerifyAsync, ReverseAsync, RequestReversalAsync, RequestRefundAsync
Depends on HttpClient
Used by PaymentRefundService, ScanToPayController, StpCallbackProcessor
See

ScanToPayMiddlewareService

Zakhaa's EFT Corp middleware: start Scan to Pay / XPay payments, finalise, status, and the tenant (float) balance.

File Services/ScanToPayMiddlewareService.cs
Main methods InitiateAsync, InitiateXPayAsync, FinalizeAsync, GetStatus, GetBalance
Depends on HttpClient, IOptions<ScanToPayMiddlewareOptions>
Used by QRCodeLookUpController, ScanToPayController, ScanToPayRepository, ScanToPayStatusBackgroundService
See

EftCorpPayloadDecryptor

Decrypts EFT Corp's AES-encrypted notifications with EFT_Corp_Notification_Key; rejects plain JSON.

File Utilities/EftCorpPayloadDecryptor.cs
Main methods DecryptSTPPayload
Used by ScanToPayController
See §6.10

21.3 Cash devices

CashDeviceApi

Conventions for the PaySolution API: the partner policy and token check (RequirePaySolutionClient), the client ID from the token, and JSON body reading.

File Features/CashDevices/CashDeviceApi.cs
Main methods RequirePaySolutionClient, AcceptsJsonBody, ClientId, ReadBodyAsync
Used by GetTransactionStatus, RequestWithdrawal, ReverseWithdrawal, SubmitTransaction, VerifyWallet
See §13.10

CashDeviceRules

Shared rules: fee codes, OTP length/validity/attempts, 24-hour reversal window, amount parsing, unique-violation detection, hashing and checking withdrawal OTPs.

File Features/CashDevices/CashDeviceRules.cs
Main methods Fail, RequireClientId, IsUniqueViolation, NormaliseWalletId, IsZar, TryParseAmount, FormatAmount, TryParseDeviceTime, ValidateDenominations, ComputePayloadHash
Used by CashDeviceDb, GetTransactionStatus, RequestWithdrawal, ReverseWithdrawal, SubmitTransaction, VerifyWallet
See §13.10

21.4 Identity, security and access

DeviceAuthenticationHandler

The Device scheme: checks the device signature headers, replay and clock-skew windows, and the RSA signature against the bound device's key.

File Signing/DeviceAuthenticationHandler.cs
Depends on IOptionsMonitor<AuthenticationSchemeOptions>, UrlEncoder, IMemoryCache, IDeviceRepository
See §6.2

MultiSchemeHandler

The JwtAndDevice rule behind [AuthorizeStrict]: both identities must be present and belong to the same user.

File Signing/MultiSchemeRequirement.cs
See §6.2

MerchantApiKeyAuthenticationHandler

The MerchantApiKey scheme: looks the key up by prefix, compares its hash in constant time, checks mode and merchant status.

File Signing/MerchantApiKeyAuthenticationHandler.cs
Depends on IOptionsMonitor<AuthenticationSchemeOptions>, UrlEncoder, AppDBContext
Used by GatewayApi
See §6.7

ValidateApiClientTokenAttribute

Rejects partner tokens whose jti is unknown, revoked or expired (the endpoint-filter twin does the same for slices).

File Signing/Filters/ValidateApiClientToken.cs
Main methods OnActionExecutionAsync
See §6.6

CallerExtensions

Ownership helpers used by every app endpoint: CallerId, IsUser, OwnsWalletAsync, IsStaff.

File Signing/CallerExtensions.cs
Main methods CallerId, IsStaff, IsUser, OwnsWalletAsync
See §6.3

ApiSigningHandler

Signs outgoing calls to Zakhaa's middlewares (RSA-SHA256 over date, key ID and body).

File Signing/ApiSigningHandler.cs
See §6.8

AuthorizeStrictAttribute

[AuthorizeStrict] = policy JwtAndDevice. Siblings: AuthorizeDeviceAttribute (DeviceOnly), AuthorizeJwtAttribute (JwtOnly), AuthorizeWebJwtAttribute (WebJwtOnly).

File Signing/AuthorizeStrictAttribute.cs
See §6.1

JwtToken

App tokens: 30-minute access tokens, 14-day refresh tokens with their own audience, and validation of POS terminal session tokens.

File Utilities/JwtToken.cs
Main methods GenerateAccessToken, GenerateRefreshToken, ValidateJwtToken
Used by AuthController, TerminalController
See §6.4

TokenProvider

Admin tokens: access token (sub, email, roles), refresh-token hashing, and the 10-minute 2FA setup token.

File Services/TokenProvider.cs
Main methods Create, HashRefreshToken, CreateTwoFactorSetupToken, ValidateTwoFactorSetupTokenAsync
Used by AdminAuthController
See §6.5

AdminAuthSecurityService

Admin login security: lockout handling and alert email, TOTP verification, encrypted authenticator keys, hashed recovery codes (using one forces 2FA re-enrolment).

File Services/Auth/AdminAuthSecurityService.cs
Main methods HandleAdminLockoutAsync, CreateLockoutError, ResetAuthenticatorKeySafeAsync, ValidateTwoFactorAsync, GenerateAndStoreHashedRecoveryCodesAsync, TryRedeemRecoveryCodeAsync, GetDecryptedAuthenticatorKeyAsync, EnsureEncryptedAuthenticatorKeyAsync, VerifyAuthenticatorCodeAsync
Depends on UserManager<IdentityUser>, AppDBContext, IEmailSender, IOptions<IdentityOptions>, EncryptionService, IHttpContextAccessor
Used by AdminAuthController
See §6.5

OtpSmsService

OTPs: creates 6-digit codes, emails them (with the right template per purpose), stores them in memory for 5 minutes, verifies them once with a 5-attempt limit, and the store-review test code.

File Utilities/OtpSmsService.cs
Main methods SendToPhone, SendToEmail, VerifyOtp
Depends on IMemoryCache, IEmailSender, AppDBContext, IConfiguration
Used by AuthController, CashSendController, PayshapController, ReconciliationController, SettingsController, UserController, WithdrawalController, ZapperController
See §6.4

PasswordValidator

App PIN rules (6–8 characters with a digit) and hashing: PBKDF2 for new PINs, verification of old SHA-256 hashes with automatic upgrade.

File Utilities/PasswordValidator.cs
Main methods ValidateLength, HasUpperCase, HasLowerCase, HasDigit, HasSpecialChar, ValidatePassword, GenerateSalt, HashPassword, ComparePasswords, HashPin
Used by AuthController, AuthRepository, UserController, UserRepository
See §6.4

EncryptionService

AES-256-GCM encryption for stored secrets (admin TOTP keys, merchant signing secrets, voucher PINs); still decrypts older AES-CBC values.

File Services/EncryptionService.cs
Main methods Encrypt, Decrypt
Depends on EncryptionOptions
Used by AdminAuthSecurityService, AppDBContext.ZakhaaVoucher, MerchantSecretStore
See §6.8

CryptoUtils

RSA signature verification (device signatures and challenges) and SHA-256 helpers.

File Utilities/CryptoUtils.cs
Main methods VerifySignature, Sha256Base64Async
Used by AuthController, DeviceAuthenticationHandler
See §6.2

CurrentUserService

Resolves the logged-in admin's Admins.Id from the token (used for auditing).

File Services/CurrentUserService.cs
Main methods GetUserId
Depends on IHttpContextAccessor, UserManager<IdentityUser>, AppDBContext
Used by AddDomain, AddReviewNote, AdminAuthController, AdminRefunds, CreateApiKey, CreateFeePlan, CreateMerchant, DocumentController (+13 more)
See §6.5

AdminAuditService

Writes AuditLogs entries for admin actions.

File Services/AdminAuditService.cs
Main methods Log
Depends on AppDBContext, IHttpContextAccessor
Used by AddDomain, AddReviewNote, AdminAuthController, AdminRefunds, CampaignMediaFileService, CreateApiKey, CreateFeePlan, CreateMerchant (+16 more)
See §6.5

UserActivityService

Writes UserActivityLogs entries.

File Services/UserActivityService.cs
Main methods Log
Depends on AppDBContext, IHttpContextAccessor
See §17.1

AWSSecrets

Loads SecretKeys from AWS Secrets Manager at startup (production) and holds them for the whole app.

File Utilities/AWSSecrets.cs
Main methods GetSecret
Used by AdminAuthSecurityService, ApiClientAuthController, ApiSigningHandler, AppDBContext.ZakhaaVoucher, CampaignMediaFileService, DiditWebhookRepository, EftCorpPayloadDecryptor, EmailSender (+11 more)
See §3.2

SymmetricEncryption

Older passphrase-based AES helper (kept for compatibility).

File Utilities/SymmetricEncryption.cs
Main methods Encrypt, Decrypt

21.5 Users, KYC and accounts

AuthRepository

Registration (personal and business, in one transaction with wallet, settings and preferences; promo credit), PIN check at login, and building the full user data returned after sign-in (wallets, businesses, transactions, vouchers, spendable balances).

File Repositories/AuthRepository.cs
Main methods AddUserAsync, LoginAsync, GetUserData, RegisterAsync, RegisterBusinessAsync, UpdatePasswordAsync, UserExist, LogAuthenticationAsync
Depends on AppDBContext, IUserRepository, IBusinessRepository, IWalletRepository, IKYCRepository, ISettingsRepository, IAmlRepository, ITransactionRepository, IZakhaaVoucherRepository, LocalisationService
Used by AuthController, TerminalController, UserController
See §6.4

UserRepository

User lookups (by ID, phone, email, ID number), creation, soft deletion, PIN/phone/email changes, and campaign audience filtering.

File Repositories/UserRepository.cs
Main methods ConfirmPassword, CreateAsync, DeleteAsync, ExistsByEmailAsync, ExistsByIDAsync, ExistsByPhoneAsync, DeletePushToken, GetAllAsync, GetByEmailAsync, GetByIdAsync
Depends on AppDBContext
Used by AuthController, AuthRepository, CampaignAudienceResolver, NotificationRepository, SettingsController, TerminalController, UserController
See §13.22

DeviceRepository

Device bindings: lookup (active only), register (deactivates the previous device), last-seen and app-version updates, unlink.

File Repositories/DeviceRepository.cs
Main methods GetAsync, GetAsyncByUserId, RegisterAsync, UpdateLastSeenAsync, UpdateAppVersionAsync, DeleteAsync
Depends on AppDBContext
Used by AuthController, DeviceAuthenticationHandler, TerminalController
See §6.4

BusinessRepository

Business records (registration number, address, bank account, MCC).

File Repositories/BusinessRepository.cs
Main methods CreateAsync, GetAllAsync, GetByBusIDAsync, ExistByBusIDAsync, GetByUserID, UpdateAsync
Depends on AppDBContext, IWalletRepository
Used by AuthRepository
See §13.22

KYCRepository

Legacy KYC profiles (ID number, status, document and selfie images as bytes).

File Repositories/KycRepository.cs
Main methods CreateKYCProfileAsync, DeleteKYCProfileAsync, GetByIDAsync, GetByUserIDAsync, UpdateKYCProfileAsync
Depends on AppDBContext
Used by AuthRepository, KycController
See §13.18

DiditClientService

Creates Didit verification sessions.

File Services/DiditClientService.cs
Main methods GetSessionLinkAsync
Depends on HttpClient, IConfiguration
Used by AuthController
See §13.18

DiditWebhookRepository

Didit webhook intake: checks the HMAC signature and queues approved results for processing.

File Repositories/DiditWebhookRepository.cs
Main methods HandleAsync
Depends on IBackgroundTaskQueue, IServiceScopeFactory
Used by AuthController
See §13.18

DiditKycService

Applies a Didit result: compares ID number, date of birth and names with the profile, stores UserKycs, sets verified / needs review / frozen, and tells the app.

File Services/DiditKycService.cs
Main methods ProcessFinalResultAsync, MessageResolver
Depends on IServiceProvider, NotificationService
Used by DiditWebhookRepository
See §13.18

FileUploadService

KYC documents: validates size and type, scans with ClamAV, stores in S3, lists, deletes and signs download URLs.

File Services/FileUploadService.cs
Main methods UploadFileAsync, GetUserDocumentsAsync, GetFileDownloadUrlAsync, DeleteFileAsync, FileUploadData, DocumentMetadataData, FileDownloadUrlData, FileDeleteData
Depends on IAmazonS3, ClamClient, AppDBContext
Used by FileUploadController
See §6.12

SettingsRepository

User settings and the app-version keys in AppSettings.

File Repositories/SettingsRepository.cs
Main methods CreateAsync, DeleteAsync, GetByIdAsync, GetByUserIDAsync, UpdateAsync, GetAppVersionInfoAsync, SetValueAsync
Depends on AppDBContext
Used by AuthRepository, SettingsController
See §19.5

AddressRepository

User addresses (CRUD).

File Repositories/AddressRepository.cs
Main methods CreateAsync, DeleteByIdAsync, GetAllByUidAsync, GetbyIdAsync, UpdateAsync
Depends on AppDBContext
Used by AddressController
See §20.3

PromoRepository

Promo code lookups.

File Repositories/PromoRepository.cs
Main methods GetPromoCode, isExistAsync
Depends on IPromoRepository, AppDBContext
See §13.11

AmlRepository

AML flags (CRUD); flags are created by the fraud model.

File Repositories/AmlRepository.cs
Main methods CreateAsync, DeleteAsync, FlagExists, GetAllAsync, GetByIdAsync, GetByUidAsync, UpdateAsync
Depends on AppDBContext
Used by AuthRepository
See §7.6

CardRepository

Legacy card records (controller disabled).

File Repositories/CardRepository.cs
Main methods CreateAsync, DeleteAsync, GetByIdAsync, GetByUserIDAsync, UpdateAsync
Depends on AppDBContext

DisputeRepository

Legacy disputes (controller disabled).

File Repositories/DisputeRepository.cs
Main methods GetByIdAsync, GetbyTransactionIdAsync, CreateAsync, UpdateAsync
Depends on AppDBContext

AdminRepository

Looks up an admin by Identity ID.

File Repositories/AdminRepository.cs
Main methods GetAdminByIdentityIdAsync
Depends on AppDBContext
Used by AdminService

AdminService

Thin wrapper over AdminRepository used by the campaign controller.

File Services/AdminService.cs
Main methods GetAdminByIdentityIdAsync
Depends on IAdminRepository
Used by DisplayAdController, MarketingCampaignController

LocalisationService

Area and postal code: from GPS via Google Geocoding (registration) or from the IP address via ipwho.is (login).

File Services/LocalisationService.cs
Main methods GetLocationFromIpAsync, GetAreaOrCityFromGoogleAsync
Depends on IHttpClientFactory, IConfiguration
Used by AuthController, AuthRepository
See

SapoPostalService

South African postal codes: converts the SA Post Office spreadsheet to JSON at startup (SapoExcelToJson) and serves it.

File Services/SapoPostalService.cs
Depends on IHostEnvironment
Used by UserController
See

21.6 Notifications and real-time

NotificationService

Real-time and push delivery: SignalR events to a user's group (WalletUpdated, UserUpdated, BigBrotherShop), payment-result broadcasts, and Expo push notifications in batches of 100.

File Services/NotificationService.cs
Main methods TransactionUpdate, UserUpdate, SendPushNotificationAsync, SendPushNotificationsAsync, SendPushNotificationWithTicketAsync, SendPushNotificationsWithTicketsAsync, BroadcastPaymentResult
Depends on IHubContext<NotificationHub>, IHttpClientFactory, IMemoryCache
Used by AuthController, CheckoutPaymentService, DiditKycService, NotificationRepository, PaymentRefundService, PushCampaignProcessor, ScanToPayController, StpCallbackProcessor (+3 more)
See §13.19

NotificationRepository

In-app notifications (list, mark read), push tokens and preferences, broadcasts, the message text for each transaction type, and the post-credit notification used by Pay@ and card top-ups.

File Repositories/NotificationRepository.cs
Main methods GetMyNotificationsAsync, MarkAsReadAsync, MarkAllAsReadAsync, SetToken, SetDeviceNotificationPreferenceAsync, BroadcastPushNotificationAsync, BroadcastByUidPushNotificationAsync, GenerateNotification, SendWebhookNotification, SendNotificationAsync
Depends on AppDBContext, IUserRepository, NotificationService
Used by CashDeviceDb, CashSendRepository, NotificationsController, PayAtBillPayController, PaymentRefundService, PayshapRepository, PayshapStatusBackgroundService, ReconciliationController (+8 more)
See §13.19

NotificationHub

The SignalR hub at /notifHub: join your own user group (authenticated) or subscribe to a payment result (open, keyed by reference).

File Services/NotificationHub.cs
Main methods OnConnectedAsync, OnDisconnectedAsync, JoinUserGroup, LeaveUserGroup, SubscribeToPayment, UnsubscribeFromPayment
Depends on IMemoryCache
Used by NotificationService
See §6.9

WalletHub

An older SignalR hub (not mapped in the pipeline).

File Services/WalletHub.cs
Main methods OnConnectedAsync, OnDisconnectedAsync, JoinUserGroup

EmailSender

Sends email through SMTP2GO (mail-eu.smtp2go.com:587, TLS), with optional base64 attachments.

File Utilities/EmailSender.cs
Main methods SendEmail
Used by AdminAuthController, AdminAuthSecurityService, DocumentController, EmailCampaignProcessor, OtpSmsService, SendEmailController, VasService, WebhookDeliveryWorker
See §13.19

EmailTemplates

HTML email templates (confirmation, password reset, admin credentials, recovery codes, lockout, document rejected …).

File Utilities/EmailTemplates.cs
Main methods EmailConfirmation, PasswordReset, TemporaryAdminCredentials, TwoFactorRecoveryCodes, AdminAccountLocked, AdminLockoutAlert, DocumentRejected, MarketingCampaign
Used by AdminAuthController, AdminAuthSecurityService, DocumentController, EmailCampaignProcessor
See §13.19

21.7 Marketing and content

MarketingCampaignService

Campaign management: create (internal or external audience), update, schedule, queue, cancel, retry, reach estimate, runs, external contact imports and subscriptions.

File Services/MarketingCampaignService.cs
Main methods CancelCampaignAsync, GetCampaignByIdAsync, CreateCampaignAsync, CreateExternalCampaignAsync, CreateInternalCampaignAsync, GetCampaignsAsync, QueueCampaignAsync, RetryCampaignAsync, UpdateCampaignAsync, EstimateReachByIdAsync
Depends on IMarketingCampaignRepository, AdminAuditService, AppDBContext, ICampaignAudienceResolver
Used by CampaignSchedulerBackgroundService, MarketingCampaignController
See §13.20

MarketingCampaignRepository

Campaign data: due campaigns, claiming runs, run and campaign status, recipients and deliveries.

File Repositories/MarketingCampaignRepository.cs
Main methods GetRunStatusAsync, GetCampaignsAsync, GetCampaignByIdAsync, GetScheduleByCampaignIdAsync, GetByIdWithDetailsTrackingAsync, GetByIdWithDetailsAsync, AddAsync, TryUpdateStatusAsync, SaveChangesAsync, GetRunsByCampaignIdAsync
Depends on AppDBContext, ICampaignAudienceResolver
Used by CampaignExecutionBackgroundService, CampaignExecutionService, CampaignSchedulerBackgroundService, ExpoReceiptBackgroundService, MarketingCampaignService
See §13.20

CampaignAudienceResolver

Turns an audience definition (all, selected, filtered, external contacts) into recipients, applying suppressions.

File Services/CampaignAudienceResolver.cs
Main methods ResolveAsync, ExternalResolveAsync
Depends on IUserRepository, AppDBContext
Used by MarketingCampaignRepository, MarketingCampaignService
See §13.20

CampaignExecutionService

Executes a campaign run by handing recipients to the channel processor.

File Services/CampaignExecutionService.cs
Main methods ExecuteRunAsync
Depends on IMarketingCampaignRepository, IEnumerable<ICampaignChannelProcessor>
Used by CampaignExecutionBackgroundService
See §13.20

PushCampaignProcessor

Sends campaign pushes through Expo with rate limiting and records tickets for receipt checking.

File Services/Processors/PushCampaignProcessor.cs
Main methods ProcessAsync
Depends on NotificationService, IOptions<CampaignRateLimitOptions>, AppDBContext
Used by CampaignExecutionService, CarouselCampaignProcessor, EmailCampaignProcessor, InAppPopupCampaignProcessor, LandingPageCampaignProcessor, SmsCampaignProcessor
See §13.20

EmailCampaignProcessor

Sends campaign emails with rate limiting.

File Services/Processors/EmailCampaignProcessor.cs
Main methods ProcessAsync
Depends on IEmailSender, AppDBContext, IOptions<CampaignRateLimitOptions>
Used by CampaignExecutionService, CarouselCampaignProcessor, InAppPopupCampaignProcessor, LandingPageCampaignProcessor, PushCampaignProcessor, SmsCampaignProcessor
See §13.20

SmsCampaignProcessor

SMS channel: not implemented (throws). Same for InAppPopupCampaignProcessor, CarouselCampaignProcessor, LandingPageCampaignProcessor.

File Services/Processors/SmsCampaignProcessor.cs
Main methods ProcessAsync
Used by CampaignExecutionService, CarouselCampaignProcessor, EmailCampaignProcessor, InAppPopupCampaignProcessor, LandingPageCampaignProcessor, PushCampaignProcessor
See §13.20

CampaignMediaFileService

Campaign media: virus scan, S3 upload, download, delete (audited).

File Services/CampaignMediaFileService.cs
Main methods DeleteFileAsync, GetFileAsync, UploadFileAsync
Depends on IAmazonS3, ClamClient, AppDBContext, AdminAuditService
Used by MarketingCampaignController
See §13.20

DisplayAdService

In-app display ads for the home screen, and their impression/click events (DisplayAdsRepository).

File Services/DisplayAdService.cs
Main methods GetDisplayAdsEventsAsync, PostDisplayAdsEventsAsync, GetHomeDisplayAdsAsync
Depends on AdminAuditService, IDisplayAdsRepository
Used by DisplayAdController
See §13.20

DashboardService

Builds the admin dashboard overview figures (counts and sums for a date).

File Services/DashboardService.cs
Main methods GetOverviewAsync
Depends on AppDBContext
Used by DashboardController
See §20.4

21.8 Outside integrations

PayshapService

Zakhaa's PayShap middleware (signed): send a bank payment, check its status, top up the EFT Corp and Flash floats, register callbacks.

File Services/PayshapService.cs
Main methods InitiateBankTransferAsync, CheckPaymentStatus, LoadEFTCorpWallet, LoadFlashWallet, RegisterCallback, UnRegisterCallback
Depends on HttpClient
Used by DiditClientService, PayshapController, PayshapRepository, PayshapStatusBackgroundService, ScanToPayRepository, VasService
See §13.14

ZapperService

Zapper middleware: decode a QR and notify payment results.

File Services/ZapperService.cs
Main methods InitiateAsync, NotifyAsync
Depends on HttpClient
Used by QRCodeLookUpController, ZapperController, ZapperRepository
See

FlashClientService

Flash API: OAuth token, product catalogue (and Zakhaa's active products), and every VAS operation.

File Services/FlashClientService.cs
Main methods GetAccessTokenAsync, GetZakhaaActiveProductsAsync, GetVendorsByCategoryAsync, GetAccountProductsAsync, GetProductsByGroupAsync, GetProductByIdAsync, PurchaseCellularPinlessAsync, Purchase1VoucherAsync, Disburse1VoucherAsync, Redeem1VoucherAsync
Depends on IHttpClientFactory, IConfiguration, AppDBContext
Used by FlashController, VasService
See §13.9

BarcodeImageService

Renders Code 128 barcodes (the Pay@ deposit barcode).

File Services/BarcodeImageService.cs
Main methods GenerateCode128Png
Used by WalletController
See §13.7

21.9 Background jobs

QueuedHostedService

Runs work items from BackgroundTaskQueue (for example EFT Corp callback processing, Didit results).

File Background/QueuedHostedService.cs
Depends on IBackgroundTaskQueue
See

BackgroundTaskQueue

In-memory queue of background work items.

File Background/BackgroundTaskQueue.cs
Main methods EnqueueAsync, DequeueAsync
Used by CheckoutPaymentService, DiditWebhookRepository, QueuedHostedService, ScanToPayController, ScanToPayRepository, VasService
See

ScanToPayStatusBackgroundService

Every 20 s: settles pending Scan to Pay payments, refunding failures.

File Background/ScanToPayStatusBackgroundService.cs
Depends on IServiceProvider
See

StpTopUpReconciliationSweep

Every 2 min: re-processes EFT Corp callbacks still pending.

File Background/StpTopUpReconciliationSweep.cs
Depends on IServiceScopeFactory
See §13.3

PayshapStatusBackgroundService

Every minute: checks pending PayShap payments, finalising or refunding.

File Background/PayshapStatusBackgroundService.cs
Depends on IServiceProvider
See §13.14

LimitRefresherBackgroundService

Daily (SAST): resets dailySpent; on the 1st also monthlySpent.

File Background/LimitRefresherBackgroundService.cs
Depends on IServiceProvider
See §7.4

TrustTierRecomputeJob

Scheduled from 02:00 SAST: recomputes wallets' trust tiers (skips frozen and overridden ones).

File Background/TrustTierRecomputeJob.cs
Depends on IServiceScopeFactory
See §7.4

CampaignSchedulerBackgroundService

Every 30 s: turns due campaigns into runs.

File Background/CampaignSchedulerBackgroundService.cs
Depends on IServiceScopeFactory
See §13.20

CampaignExecutionBackgroundService

Every 60 s: claims and executes queued runs.

File Background/CampaignExecutionBackgroundService.cs
Depends on IServiceScopeFactory
See §13.20

ExpoReceiptBackgroundService

Every minute: checks Expo push receipts and updates delivery status.

File Background/ExpoReceiptBackgroundService.cs
Depends on IServiceScopeFactory, IHttpClientFactory
See §13.19

CheckoutExpiryJob

Every minute: expires unpaid checkout sessions (grace for pending ones; flagged ones are skipped) and sends payment.expired.

File Background/CheckoutExpiryJob.cs
Main methods RunOnceAsync
Depends on IServiceScopeFactory
See §13.1

WebhookDeliveryWorker

Every 5 s: delivers merchant webhooks from the outbox, re-signed per attempt, with retries up to 24 hours.

File Background/WebhookDeliveryWorker.cs
Main methods RunOnceAsync
Depends on IServiceScopeFactory, IHttpClientFactory
See §13.1

21.10 Plumbing (messaging, data, startup)

Sender

The in-house dispatcher (ISender.Send) that runs a slice's handler through the pipeline behaviours.

File Common/Messaging/Sender.cs
Main methods Send
Depends on IServiceProvider
Used by Abstractions, AddDomain, AddReviewNote, AdminRefunds, CreateApiKey, CreateCheckoutSession, CreateFeePlan, CreateMerchant (+38 more)
See §4.2

ValidationBehavior

Runs FluentValidation before a handler (422 validation_error on failure).

File Common/Messaging/Behaviors.cs
Main methods Handle
Depends on IEnumerable<IValidator<TRequest>>
Used by Abstractions, MessagingRegistration, Sender
See §4.2

TransactionBehavior

Wraps every command in a database transaction (unless it implements IManagesOwnTransaction).

File Common/Messaging/Behaviors.cs
Main methods Handle
Depends on AppDBContext
Used by Abstractions, MessagingRegistration, Sender
See §4.2

LoggingBehavior

Logs each request handled through the dispatcher.

File Common/Messaging/Behaviors.cs
Main methods Handle
Used by Abstractions, MessagingRegistration, Sender
See §4.2

EndpointExtensions

Discovers every IEndpoint and maps it (MapEndpoints).

File Common/Messaging/Endpoints.cs
Main methods AddEndpoints, MapEndpoints
See §4.2

ResultHttpExtensions

ToHttpResult: turns a Result into the right HTTP status and JSON (Newtonsoft).

File Common/Messaging/Endpoints.cs
Main methods ToHttpResult
See §4.2

HttpBody

Reads a JSON request body with the app's Newtonsoft settings (used by the slices).

File Common/Messaging/Endpoints.cs
Main methods ReadAsync
Used by AddDomain, AddReviewNote, AdminRefunds, CashDeviceApi, CreateApiKey, CreateCheckoutSession, CreateFeePlan, CreateMerchant (+8 more)
See §20.0

AppDBContext

The main database context, split into partial files per domain (§8), with UTC timestamp handling and the voucher PIN encryption converter.

File Data/AppDBContext.cs
Depends on DbContextOptions<AppDBContext>
Used by AddDomain, AddReviewNote, AddressRepository, AdminAuditService, AdminAuthController, AdminAuthSecurityService, AdminController, AdminRefunds (+140 more)
See

ApplicationIdentityDbContext

ASP.NET Identity database (admins, roles, admin refresh tokens).

File Data/ApplicationIdentityDbContext.cs
Depends on DbContextOptions<ApplicationIdentityDbContext>
Used by AdminAuthController
See

ApiDbContext

Partner OAuth clients and tokens.

File Data/ApiDbContext.cs
Depends on DbContextOptions<ApiDbContext>
Used by ApiClientAdminController, ApiClientAuthController, ValidateApiClientToken
See

PipelineExtensions

Middleware order, forwarded headers, health check, KYC webhook body buffering.

File Extensions/PipelineExtensions.cs
Main methods AddForwardedHeaders, ConfigurePipelineAsync
See

GatewayExtensions

Registers the gateway: merchant API-key scheme, CORS, rate limits, webhook HTTP client (SSRF-safe), jobs.

File Extensions/ServiceExtensions/GatewayExtensions.cs
Main methods AddGateway, AddGatewayWebhookHttpClient
See §6.7

HttpClientExtensions

Registers every outside HTTP client with its base address (and request signing where needed).

File Extensions/ServiceExtensions/HttpClientExtensions.cs
Main methods AddHttpClients
See

PaginationExtensions

Paging helpers producing PagedResult<T>.

File Common/PaginationExtensions.cs
Main methods Pages, ToPagedResultAsync
Used by TransactionsController, UserController
See §20.0