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
-
The ledger is the truth. Every balance change
writes a
WalletLedgersrow (opening, change, closing).Wallet.balanceis only a cache of the latest closing balance (§7). -
Money in is a
Topupsrow, money out is aWithdrawalrow, whatever the rail. The app's history is built from both, plus the rail tables (§7.1). -
Money code locks the wallet (
SELECT … FOR UPDATE) and writes the business row and the ledger row in one transaction. - "Strict" means two proofs. App money endpoints need a user JWT and a device signature from the same user.
- Every app endpoint checks ownership. A wallet, user or record ID in a request must belong to the caller, otherwise 403 (§6.3).
-
Two coding styles. Older features are MVC
controllers with repositories; newer ones (gateway, refunds, cash
devices) are vertical slices in
Features/(§4). - Migrations are applied by hand. Nothing migrates on startup (§3.5).
1.4 Recommended codebase reading order
-
Program.cs, thenExtensions/ServiceExtensions/*(what is registered) andExtensions/PipelineExtensions.cs(request order). -
Signing/(how requests are authenticated) andSigning/CallerExtensions.cs(ownership). -
Repositories/WithdrawalRepository.cs(Send Zakhaa: the clearest example of a full money transaction). -
Services/Ledger/DbWalletLedger.cs(reserve → finalise → refund, used by most external rails). -
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
Configsection ofappsettings.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_Address, Email_Username,
Email_Pwd, PasswordResetUrl,
LockoutAlertTo
|
EmailSender, admin lockout alerts |
|
| AWS S3 |
BucketName, Region,
AccessKey, SecretKey
|
KYC documents, campaign media |
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();
}
-
IEndpointclasses are discovered by reflection and mapped byapp.MapEndpoints(). -
ISenderis an in-house dispatcher (no MediatR). Behaviours inCommon/Messaging/Behaviors.cs:-
run FluentValidation (failures become
422 validation_error); -
wrap every
ICommandin a DB transaction. A command that locks rows, commits in steps or calls outside services implementsIManagesOwnTransactionto opt out.
-
run FluentValidation (failures become
-
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)
-
Create
Features/<Area>/<Action>/<Action>.cswith the request, handler and anIEndpoint. -
Pick the right auth (§6):
RequireMerchantApiKey(),RequireGatewayAdmin(),.RequireAuthorization("ApiClient:…"), or[AuthorizeStrict]for app endpoints. -
If it moves money: lock the wallet row, write Topups or Withdrawal
and a ledger row in one transaction, and use
IManagesOwnTransactionif you manage commits yourself. -
Audit admin writes (
AdminAuditService.AuditAsync). - Add tests in
tests/WalletApi.Gateway.Tests. - Add it to this handbook (§12 and §15).
5. Request pipeline
Order of middleware (Extensions/PipelineExtensions.cs):
-
UseForwardedHeaders: the real client IP comes fromX-Forwarded-For. UseRateLimiter(§6.11).UseHttpsRedirection.- Development only: Swagger and seed data.
MapHealthChecks("/healthz").-
UseRouting,UseCors()(default policy; named policies per endpoint). -
UseAuthentication,UseAuthorization. -
MapHub<NotificationHub>("/notifHub")(CORSSignalRCors). -
UseKycWebhookBuffering: lets the Didit webhook read its raw body for HMAC checking. -
MapControllers(), thenMapEndpoints()(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, andRole="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-Idwas 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#1BEGIN 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):
- The PIN is validated first (6–8 characters including a digit), so a bad PIN doesn't use up the OTP.
-
The OTP is verified (requested with
/api/auth/otp, typeEmailVerification). -
In one transaction: the user, a personal wallet, settings and
notification preferences are created. A business also gets a
Businessrow and a business wallet. An optional promo code credits the wallet, with a ledger row. - 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>inUsers.password(salt inUsers.salt), viaPasswordValidator.HashPinandVerifyPin. - 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.GenerateRefreshTokencreates 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 cookierefresh_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
TwoFactorSetupRequiredand 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.
-
an admin without it gets
428
-
Tokens:
-
access token from
TokenProvider(claimssub,email, roles), validExpirationInMinutes(default 5); -
refresh token: 32 random bytes, only its SHA-256 stored (
RefreshTokensin the Identity DB), sent as the HttpOnly cookierefreshToken, rotated on every refresh (the old one is revoked and linked to its replacement), validRefreshTokenExpirationDays(default 7).
-
access token from
-
Admin user management:
registerandunlock-accountneed Admin or SuperAdmin; role, status and delete need SuperAdmin. -
Audit: every admin write is logged to
AuditLogs(AdminAuditService), readable atGET /api/audit.
6.6 Partners: OAuth client credentials (ApiClient)
-
Token request:
POST /api/client-auth/token, form fieldsgrant_type=client_credentials&client_id=…&client_secret=…(rate-limited). -
Client secrets are stored as
BCrypt hashes in
OAuthClients(ApiDbContext). The client must beActive. -
The JWT:
-
claims
sub(client ID),jti,iat,client_name, andscope(taken from the client record; a requested scope is ignored); -
issuer, audience and key are the JWT secrets with
-api-clientappended; -
lifetime is per client (
TokenLifetimeSeconds).
-
claims
-
Issuing a token revokes the client's other active
tokens, except for Pay@, which may hold several. Every token is
recorded in
OAuthTokensbyjti. -
[ValidateApiClientToken](on Pay@ and the EFT Corp QR lookup) and its endpoint-filter twin (on cash devices) also check thejtiis 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_…orsk_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 belive. -
Claims:
merchant_id,merchant_mode,merchant_key_id.last_used_atis 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 twov1=signatures during that window. -
Outgoing webhooks:
X-Zakhaa-Signature: t=<unix>,v1=hex(HMAC-SHA256(secret, t + "." + body))andX-Zakhaa-Event-Id. -
Checkout links carry a signed
key. The public/verifyand/returnendpoints answer every failure with the same 404checkout.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), keyEncryptionKey; - 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.
-
AES-256-GCM (authenticated, so tampering is
detected), stored as
-
Voucher PINs are encrypted by an EF Core value
converter (
VoucherPinCryptoinData/AppDBContext.ZakhaaVoucher.cs), so code and the app see the plain PIN while the database holds ciphertext. -
ApiSigningHandlersigns calls to Zakhaa's own middlewares (PayShap, CashSend, …). It addsX-Date,X-Client-API-Key(MIGKeyId),X-NonceandX-Signature: RSA-SHA256 withRSA_Private_KeyoverX-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
-
Lock the wallet row (
SELECT … FOR UPDATE). Send Zakhaa locks both wallets in ID order to avoid deadlocks, inside a serializable transaction. -
Read the opening balance from the
last ledger row, never from
Wallet.balance. -
Write the Topups or Withdrawal row and the ledger
row in the same transaction. Set
Wallet.balance = ClosingBalance. -
ChangeAmountis positive;Directiongives the sign. - Do notifications only after the commit.
7.3 Fees
-
FeeRepository.ApplyFee(wallet, withdrawal, code): fee =amount × pct%or the fixedamount, and VAT = 15% of the fee. The payer paysamount + fee + VAT. -
Codes in use:
-
ZAKPEER: Send Zakhaa of typePayment; 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):
-
Frozen (
trust_tier_frozen) → blocked: "temporarily restricted". -
Unverified ceiling: an unverified user can't
exceed the
Unverifiedlimit per day. -
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.
-
Platform daily limit (
TransactionLimitDaily) and monthly limit (Monthly). Send Zakhaa also checks theWithdrawallimit for non-payment types. - 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:
-
FraudFeatureBuilderbuilds the features, andFraudPredictionServiceposts them to/fraud/predict. -
A score of 0.70 or more writes an
AML_Flagsrow (riskhighfrom 0.75, otherwisemedium) 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:
- Reserve: run the checks above, debit the wallet with a ledger row, and create a pending rail record.
- Call the provider.
- Finalise on success: mark the record completed, and the debit stays.
-
Refund on failure: credit the money back with a
*_refundledger 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 useResult<T>. Amounts are decimal rand. -
Slices (gateway, refunds, cash devices):
{ "isSuccess": bool, "data": …, "error": { "code", "description" } }with correct HTTP status codes and stablesnake_caseerror 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
| 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
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)
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 creditednet; fee and VAT ledger entries are written. -
Reconciliation:
EXACT,UNDERPAIDorOVERPAID(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}"), andpayment.succeededis 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
-
Verify with EFT Corp (
GET purchase/notification/{txn}), retrying while PENDING. - Approved and ours (merchant ID, ZAR, exact reference) → credit (idempotent).
-
FAILED,REVERSED,DECLINED,CANCELLEDorREFUNDED:-
first,
PaymentRefundService.ConfirmFromEftAsync: is this one of our refunds or reversals? If so, settle it; -
otherwise claw back any credit
(
ScanToPayService.ReverseTransactionAsync).
-
first,
-
Webhooks without
walletId:refare accepted only for a transaction or ref we already know (ScanToPayController.KnownReferenceAsync).
13.4 Refund through Send Zakhaa (wallet rail)
PaymentRefundService.RefundAsync, rail
zakhaa_wallet:
-
Lock the payment's Topups row (
FOR UPDATE); remaining = gross − succeeded refunds; one pending refund at a time. - Check the receiver can pay (spendable balance + this payment's own held credit).
- One transaction: receiver Withdrawal "Refund to {payer}" + ledger debit; payer Topups "Refund from {merchant}" + ledger credit.
-
If it's a session's payment:
refundedAmountCentsgoes up, the session becomesPARTIALLY_REFUNDEDorREFUNDED, andpayment.refundedis queued (a duplicate refund keeps the order paid and sendsrefund.duplicate: true). -
Push notification to the payer
(
TransactionType.PaymentRefunded).
13.5 Refund through the bank (EFT Corp rail)
-
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}). -
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}. - 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.
-
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.
-
Refund:
-
Only bank response
00counts as success. A failed reversal is also reported asREVERSED. -
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:
-
Controller checks: a user JWT and device
signature (Strict); the sender wallet's
user_idmust equal the JWT user (else 403); an OTP from R500. -
Serializable transaction, both wallets locked
FOR UPDATEin ID order. - Rejections: sending to the same wallet, an amount ≤ 0, or a frozen sender.
- Gateway check: if the reference is an open checkout session for the receiver, it follows gateway rules instead (13.2).
-
Limits: the
Withdrawallimit,CheckLimits(unverified, daily, monthly), then the trust-tier cap. -
Fee:
ZAKPEERorZAKVOUCHER(0 for gateway orders), plus 15% VAT on the fee. Total = amount + fee + VAT. - 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".
-
Writes: sender Withdrawal
(
Completed) and a debit ledger rowsend_zakhaa; the beneficiary is saved; the receiver gets a Topups row and a credit ledger row (or the merchant credit for a gateway order). OneSaveChangesand commit. -
After commit: a SignalR
TransactionUpdateto 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:
-
DbWalletLedger.ReserveAsyncruns the limit checks (§7.4), the fraud model (§7.6) and the spendable-balance check, debits the wallet and creates aVasTransactionsrow. - Flash is called.
-
On success it's finalised (receipt, voucher PIN or token). On
failure
RefundAsynccredits the money back (vas_refund).
VasService.CheckBalanceAndTopUp monitors the Flash
float.
13.10 Cash devices (PaySolution)
-
Deposit (
POST /api/cash-devices/transactions, type1): idempotent per device + transaction reference. FeeCASHDEVICE_DEPOSIT; the wallet is credited with a ledger row. -
Withdrawal:
-
POST /withdrawals/requestchecks 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. -
The device then submits type
3with the code. The wallet is debited, with feeCASHDEVICE_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)
| 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,
Withdrawallimit, daily/monthly/unverified limits, trust-tier cap, spendable balance), and feeZAKVOUCHER+ 15% VAT; -
creates a 12-digit voucher number (cryptographic RNG, unique)
valid 1 year, a
Withdrawalrow (Zakhaa Voucher) and a debit ledger rowzakhaa_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 + ledgertopup) 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:
SetExpiredAsyncrefunds 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):
-
Initiate (
/api/cashsend/initiate):- de-duplicated on user, reference, same day and amount;
-
a per-transaction
CallbackTokenis generated; -
DbWalletLedger.ReserveAsync(productCASHSEND: limits, fraud, holds) debits the wallet; -
TransMS
Initiateis 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.
-
Statuses:
Pending→Active(bank accepted, withdrawal number sent to the recipient) →Redeemed(cash collected) |Cancelled(refunded) |Rejected/Failed(refunded). -
Cancel,
resend withdrawal number and
reset PIN call TransMS. Cancel is only allowed in
Pending/Activeonce TransMS has given aCashSendId; the final state arrives by webhook. -
Webhook (
/api/cashsend/webhook):- matched by end-to-end ID (our reference) or UETR;
-
Cancelled/Rejectedrefund 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 newActiverow is added. -
the
X-Callback-Tokenheader must match the transaction'sCallbackToken, otherwise 401 and nothing changes.
-
Stored data: mobile number, beneficiary name,
note,
WithdrawalNumber, UETR, IP address. The collection PIN is no longer stored (older rows may still hold one). -
Access: no OTP; the wallet must belong to the
logged-in user, whose ID replaces any
UserIdin 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):- The OTP is checked and the wallet ownership verified.
-
Duplicates are rejected (same user, reference, day and
amount). A new
TransRefGUID is set. -
ReserveAsync(PAYSHAP) debits the wallet. -
The middleware
POST /admin/mig/initiate-paymentis 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:
PayshapStatusBackgroundServicepollsGET /admin/mig/status/{ref}every minute. It movesPendingtoCompletedorFailed(refunding on failure), and also handles a lateFailed→Completed. -
Stored data: account details in
PayshapBeneficiary;PayshapTransactionsholdsUniqueEftNumber,PaymentNumber,ApiRef, IP address.
13.15 Zapper and Scan to Pay (paying a merchant's QR from the app)
-
Zapper:
-
initiatedecodes the scanned base64 QR via the Zapper middleware; -
finalizede-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):
-
initiatestpstores aScanToPayrow 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 middlewareFinalize. APENDINGresult is polled twice, 5 seconds apart; anything notSUCCESSFULis refunded. Success → finalised, push sent, and the EFT Corp float check is queued. -
The tip
(
AdditionalFields.tipAmount) is added to the wallet debit insideDbWalletLedger.ReserveAsyncand recorded asTipAmount; 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 opaqueTagIdand a URL to write onto the tag. StatusPending. -
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 isActiveand the wallet is active. Every other case returns the sametag_unavailable, so tag IDs can't be probed.
13.17 Bank reconciliation (unmatched deposits)
Request/response examples: §20.11
-
Statement lines live in
StatementTransactionsand are loaded outside this API: nothing in this code inserts them. -
Unmatched credit: a statement line with
Amount > 0with no top-up whereamount + fee + vatequals the line amount and the top-up reference contains the line'sTransactionId. - 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 rowmanual_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)
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
UserKycsrow: 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, thenis_verified = trueandneed_review = false. -
An admin rejects: the document becomes
Rejected,is_verified = false, and the user is emailed the reason. -
unverifyresets 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.
|
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
-
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.
-
campaign:
-
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:
-
The terminal sends a session JWT minted by the POS service
with the same
JWT_SecretKey, with claimsserialnumber(must equal the body'sSN) andmerchantId(= the owner's ID number). -
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. -
Unknown merchant: details are fetched from
Terminal:BootstrapUrl(default: the hard-coded test servicehttps://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
-
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).
-
created into the file
-
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 whenneed_reviewis set. -
App version gate:
AppSettingskeysLatestAppVersion,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:
-
ApiFactorysets dummy secrets (JWT,EncryptionKey, S3, ClamAV …) and runs asDevelopment. -
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
-
Create
appsettings.Development.jsonwith aConfigsection holding everySecretKeysvalue (§3.2), using UAT or local values. It's git-ignored. -
Run
dotnet run. Swagger is at/swaggerand seed data is created at startup. - 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
RefundWebhookTokenis applied in production. -
deploy-V2.shrunsdocker volume prune -f, which deletes volumes not attached to a running container.uat-deploy.shdeliberately doesn't. -
Wallet.balanceis a cache. Fix balances through the ledger, never by editingbalance. -
HTTP 200 doesn't mean success on older endpoints:
check
successin 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
REVERSEDcan mean a failed reversal: onlybankResponse == "00"means it happened. Reversals we didn't request still claw back regardless (anOPS: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,CardControllerandDisputeControllerare commented out.AnalyticsController,CashoutController,PublicControllerandServiceControllerhave 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.balanceis 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 middlewarehttps://payshap.zakhaapay.com/, Zapperhttps://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 aswallet_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 propertyAmountInCentscarries[JsonPropertyName("amount")], but the key the API actually binds isamountInCents.
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.
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.
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).
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.
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.
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).
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).
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.
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).
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.
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.
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).
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).
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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).
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.
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).
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).
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.
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).
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.
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).
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.
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.
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.
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.
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).
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.
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).
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.
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.
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.
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.
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.
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.
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.
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).
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
Auth: Roles: SuperAdmin
Response 200:
{ "totalLinesInFile": …, "inserted": …, "skippedExisting": … }
DELETE /api/wallet/{id}
What it does: Deletes a wallet.
Auth: Roles: SuperAdmin
Route: id (string)
Response 204: no content.
GET /api/wallet/{uid}
What it does: The caller's personal wallet.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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).
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.
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.
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.
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.
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.
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.
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).
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).
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).
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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.
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).
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).
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.
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).
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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).
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).
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.
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.
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).
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.
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.
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.
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).
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.
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).
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).
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).
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.
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.
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.
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.
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).
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.
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).
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.
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.
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.
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).
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.
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.
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.
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.
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).
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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).
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).
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, …).
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.
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.
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.
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.
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.
Auth: None
Response 200:
{
"success": true,
"message": "",
"data": …
}
POST /api/WebsitePopUpMessages
What it does: Adds a pop-up message.
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.
Auth: None
Response 200:
{
"success": true,
"message": "",
"data": …
}
GET /api/WebsitePopUpMessages/active/android
What it does: Active pop-up messages for the Android app.
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, …).
Auth: None
Route: target (string)
Response 200:
{
"success": true,
"message": "",
"data": …
}
GET /api/WebsitePopUpMessages/{id}
What it does: One pop-up message.
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).
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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).
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.
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).
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.
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).
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).
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.
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).
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.
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.
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.
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.
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).
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.
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).
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.
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).
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).
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.
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).
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.
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.
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.
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.
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.
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.
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).
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).
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.
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.
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.
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).
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).
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).
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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 |