Olympus Pay System Documentation
Olympus Pay is a neobank platform for African SMBs, built on Supabase (Postgres + Edge Functions + Auth) and operating under the supervision of the Bank of Botswana Regulatory Sandbox. This document covers architecture, data models, security, provider integrations, and deployment.
At a Glance
Architecture
Olympus Pay uses a serverless, edge-first architecture. All client requests pass through Supabase's embedded Kong API Gateway before reaching edge functions or the PostgREST database layer.
Mobile / Web App (Expo)
↓ HTTPS
Kong API Gateway ←── JWT validation, CORS, rate limiting, TLS
↓
┌─────┴──────────────────────────┐
│ │
PostgREST Edge Functions (Deno)
(tables, RPCs) (business logic)
│ │
└────────┬───────────────────────┘
│
PostgreSQL 15 (RLS enforced)
│
┌──────┴──────────────────────────────────────────┐
│ │
External Providers Cloudflare R2
(Zenus, DIDIT, Duffel, (file storage)
Flutterwave, Africa's Talking,
Documenso, Resend, HotelBeds)
Request Flow
Client sends request
Expo app sends HTTPS request with Authorization: Bearer JWT and apikey: anon_key headers.
Kong validates JWT
Kong verifies the JWT signature, extracts sub (user ID), and forwards the request with the identity attached.
Edge Function runs
Deno function validates the token again via resolveUser() from _shared/security.ts, checks rate limits, then processes the request.
Database write (if needed)
All DB operations use the service-role client inside edge functions. RLS policies are still evaluated for PostgREST calls from the client.
Provider call (if needed)
Edge function calls the appropriate provider (Zenus, Flutterwave, etc.) with credentials from Supabase Vault.
Response
Function returns JSON. All responses include CORS headers. Rate-limited responses return 429.
Technology Stack
| Layer | Technology | Version / Notes |
|---|---|---|
| Mobile App | Expo / React Native | SDK 56, RN 0.85.3 |
| Web App | Expo Router (web) | Hosted on Vercel |
| Navigation | Expo Router v4 | File-based routing |
| State | Zustand | Global auth + UI state |
| Backend | Supabase | PostgreSQL 15 + Deno Edge Functions |
| API Gateway | Kong | Embedded in Supabase |
| Auth | Supabase GoTrue | JWT, OTP, OAuth-ready |
| Storage | Cloudflare R2 | S3-compatible, presigned URLs |
| Banking | Zenus / TUUM | REST API v2 |
| KYC | DIDIT | Liveness + AML |
| Payments | Flutterwave | v3 API, OAuth2 |
| Travel | Duffel | v2 API |
| Messaging | Africa's Talking | SMS, WhatsApp, USSD |
| E-Sign | Documenso | REST API |
| Resend | Transactional email |
Database Schema
All tables live in the public schema of a PostgreSQL 15 database. Row-Level Security (RLS) is enabled on every table. The auth.uid() function identifies the current user.
Core Tables
| Table | Description | Key Columns |
|---|---|---|
profiles | One row per auth user. Extended user data. | id (FK auth.users), full_name, phone, avatar_url, kyc_status, account_tier |
business_profiles | Company details for business accounts. | user_id, legal_name, trading_name, entity_type, reg_number, tax_id |
accounts | Bank account records linked to Zenus. | user_id, currency, balance, available_balance, account_number, sort_code, zenus_account_id |
transactions | All debit/credit events. | user_id, type, amount, currency, status, recipient_name, payment_rail, zenus_payment_id |
cards | Virtual and physical card records. | user_id, card_type, display_name, last_four, status, monthly_limit, pin_set, zenus_card_id |
beneficiaries | Saved payment recipients. | user_id, beneficiary_name, account_number, bank_code, payment_rail |
invoices | Invoice headers. | user_id, invoice_number, client_name, client_email, status, total, due_date |
invoice_items | Line items for invoices. | invoice_id, description, quantity, unit_price, subtotal |
payment_requests | Money-request records. | from_user_id, to_email, amount, currency, status, payment_link |
scheduled_payments | Recurring payment schedules. | user_id, amount, frequency, next_run_date, payment_method |
notifications | In-app notification feed. | user_id, type, title, body, is_read, metadata |
kyc_verifications | DIDIT session records. | user_id, session_id, status, entity_type, liveness_score, aml_hits |
ubo_declarations | Ultimate Beneficial Owner filings. | user_id, beneficial_owners (JSONB) |
compliance_alerts | AML / OFAC flags. | user_id, alert_type, severity, resolved |
travel_bookings | Flight and hotel bookings. | user_id, booking_type, provider, provider_order_id, status, total_amount |
travel_passengers | Saved passenger profiles. | user_id, is_default, given_name, family_name, passport_number |
webhook_subscriptions | Outbound webhook config. | user_id, event_type, url, secret, is_active |
statements | Monthly statement PDF references. | user_id, period_start, period_end, file_key |
Row-Level Security
Every table has RLS enabled. The general policy is: a user can only read and write their own rows. Financial write operations additionally require KYC approval.
KYC Gate Policy
Sensitive tables like transactions, cards, and scheduled_payments use an additional policy function:
CREATE POLICY "kyc_required" ON transactions FOR INSERT TO authenticated USING (is_kyc_approved(auth.uid())); CREATE FUNCTION is_kyc_approved(uid uuid) RETURNS boolean AS $$ SELECT kyc_status = 'approved' FROM profiles WHERE id = uid; $$ LANGUAGE sql SECURITY DEFINER;
compliance_alerts and audit_logs tables are restricted to the service_role key and are not accessible from client-facing requests.
File Storage (Cloudflare R2)
Olympus Pay uses Cloudflare R2 for all file storage. Files are uploaded directly via presigned URLs, keeping storage credentials server-side at all times.
Upload Flow
Request presigned URL
POST /functions/v1/cf-upload with file_type and content_type. Returns { presigned_url, key }.
PUT file directly to R2
Client sends the file bytes directly to the presigned URL. No auth header on this request.
Store the key
Save the returned key in the database (e.g., in profiles.avatar_key or transactions.receipt_key).
Retrieve via signed URL
GET /functions/v1/cf-file?key=... returns a signed URL valid for 1 hour.
Storage Buckets (Key Prefixes)
avatars/{user_id}.jpgreceipts/{user_id}/{txn_id}.jpgAuthentication
Olympus Pay uses Supabase GoTrue for authentication. All sessions are token-based — access tokens are short-lived (1 hour) and are refreshed silently using a long-lived refresh token (30 days). There are no cookies or server-side sessions.
Sign Up
POST /auth/v1/signup — creates a new user and returns an active session immediately. A confirmation email is sent if email confirmation is enabled.
{
"email": "user@example.com",
"password": "••••••••",
"data": {
"full_name": "Kefilwe Moagi",
"phone": "+26771234567"
}
}
Sign In
POST /auth/v1/token?grant_type=password — authenticates with email and password. Returns access_token, refresh_token, and the user object.
{
"email": "user@example.com",
"password": "••••••••"
}
// Response
{
"access_token": "eyJhbGci...",
"refresh_token": "v1:...",
"expires_in": 3600,
"token_type": "bearer",
"user": { "id": "uuid", "email": "...", "role": "authenticated" }
}
Refresh Token
POST /auth/v1/token?grant_type=refresh_token — exchanges a refresh token for a new access token. The Supabase JS and React Native clients handle this automatically.
{
"refresh_token": "v1:..."
}
Token Structure
Every access token is a signed JWT. The claims most relevant to edge functions:
auth.users.id and profiles.idauthenticated for signed-in users, anon for unauthenticatedauthenticatedRequired Headers
Every API request must carry both headers. Kong rejects requests missing either one.
Authorization: Bearer <access_token> apikey: <supabase_anon_key>
apikey identifies the Supabase project at the Kong gateway layer. Authorization identifies the individual user within that project. Both are required — one without the other returns 401.
Auth Errors
| Status | Cause |
|---|---|
400 | Malformed request body or missing required fields |
401 | Missing or invalid Authorization header, or expired access token |
403 | Valid token but insufficient permissions (RLS policy denied access) |
422 | Email already registered, or password too short |
KYC Flow
Identity verification is a hard gate on all financial operations — sending money, issuing cards, bulk payments, and scheduled payments all require kyc_status = 'approved'. This is enforced both at the RLS level in the database and within edge functions before any external call is made.
Verification is handled by DIDIT and covers three layers: liveness detection (biometric selfie check), document verification (passport, national ID, or driver's licence via OCR + authenticity checks), and AML screening (global sanctions, PEP, and adverse media checks).
Entity Types
| Entity Type | Documents Required | Additional Step |
|---|---|---|
individual | Government-issued photo ID + liveness selfie | — |
business | Director ID + liveness + company registration documents | UBO Declaration (all beneficial owners ≥ 10%) |
Verification Flow
Create a session
POST /functions/v1/didit-session with entity_type (individual or business) and country (ISO 3166-1 alpha-2). Returns session_id, session_url, and expiry timestamp. profiles.kyc_status is set to pending.
Open the session URL
Redirect the user to session_url — either in an in-app WebView or the system browser. DIDIT's hosted flow guides the user through liveness capture and document upload. Sessions expire after 30 minutes.
Receive the decision
DIDIT sends a signed POST to /functions/v1/didit-webhook on completion. The signature is verified via HMAC-SHA256 before any processing occurs. Alternatively, poll POST /functions/v1/didit-decision with { "session_id": "..." }.
Profile updated
The webhook handler writes the final status to profiles.kyc_status and inserts a full record into kyc_verifications — including liveness score, document type, and AML hit count. RLS policies on financial tables evaluate is_kyc_approved(auth.uid()) on every write.
Business accounts — UBO Declaration
Business entity verification also triggers a UBO Declaration requirement. POST /functions/v1/zenus-ubo-declare must be called with a list of all beneficial owners holding ≥ 10% equity before financial access is fully unlocked.
Session Request
POST /functions/v1/didit-session
{
"entity_type": "individual", // or "business"
"country": "BW" // ISO 3166-1 alpha-2
}
// Response
{
"session_id": "did_sess_...",
"session_url": "https://verify.didit.me/session/...",
"expires_at": "2026-06-15T22:00:00Z"
}
Webhook Payload
POST /functions/v1/didit-webhook
Headers: x-didit-signature: sha256=...
{
"event": "session.completed",
"session_id": "did_sess_...",
"status": "approved",
"liveness_score": 0.99,
"document_type": "PASSPORT",
"document_country": "BW",
"aml_hits": 0,
"completed_at": "2026-06-15T21:47:33Z"
}
KYC Status Reference
| Status | Description | Financial Access |
|---|---|---|
not_started | No session has been created for this user | Read-only — can view balances and history |
pending | Session created and in progress | Read-only — session URL still active |
approved | Identity verified — all checks passed | Full access to all financial operations |
rejected | Verification failed — liveness, document, or AML check | Read-only — a new session can be initiated |
AML & Screening
DIDIT runs AML checks against the following sources as part of every verification session:
- OFAC Specially Designated Nationals (SDN) list
- UN Security Council consolidated sanctions list
- EU and UK financial sanctions registers
- Politically Exposed Persons (PEP) global database
- Adverse media screening (negative news)
An aml_hits count greater than zero is recorded in kyc_verifications and triggers a compliance_alerts entry for manual review. The user's kyc_status is set to rejected until the alert is resolved.
profiles.kyc_status server-side. Edge functions call resolveUser() and then check KYC status against the database before executing any financial operation.
Account Tiers
Every account operates within a tier that defines transaction limits and feature access. Tier limits are enforced inside edge functions before any call is made to Zenus — a payment that would breach a tier limit is rejected at the application layer, not the banking rail.
| Tier | Daily Limit | Monthly Limit | Features |
|---|---|---|---|
| Starter | BWP 5,000 | BWP 20,000 | Basic transfers, 1 virtual card |
| Personal | BWP 25,000 | BWP 100,000 | All payment rails, 2 cards, scheduled payments |
| Business | BWP 100,000 | BWP 500,000 | Bulk payments, invoicing, payroll, multi-user access |
| Enterprise | Negotiated | Negotiated | Custom limits, dedicated support, API access |
Tier Upgrades
Tier changes are applied via the set_account_tier RPC. Upgrades require a verified identity (kyc_status = 'approved'); downgrades take effect immediately.
POST /rest/v1/rpc/set_account_tier
{
"user_id": "uuid",
"tier": "business"
}
Compliance
Olympus Pay is a regulated financial institution operating under the supervision of the Bank of Botswana Regulatory Sandbox. Compliance controls are embedded at every layer of the stack — from database policies to edge function logic to third-party provider checks.
Controls
| Control | Where Enforced | Detail |
|---|---|---|
| Identity verification | RLS + edge functions | KYC gate on all financial writes via is_kyc_approved() |
| AML screening | DIDIT (KYC) + Zenus (ongoing) | OFAC, UN, EU, UK sanctions + PEP + adverse media |
| UBO Declaration | Business onboarding | All beneficial owners ≥ 10% must be declared before financial access |
| Transaction limits | Edge functions | Daily and monthly caps enforced per account tier before Zenus is called |
| Transaction monitoring | Zenus webhook + compliance_alerts table | Unusual activity flagged and surfaced for review |
| Webhook integrity | All inbound webhooks | HMAC-SHA256 signature verified before any payload is processed |
| Audit trail | Database triggers | Every financial mutation recorded with user ID, timestamp, and originating IP |
| Data retention | Policy | Transaction records retained for 7 years per Bank of Botswana Regulatory Sandbox requirements |
Suspicious Activity
Transactions that trigger a compliance flag from Zenus or exceed monitored thresholds result in a record in compliance_alerts with a severity level (low, medium, high). High-severity alerts are escalated to Suspicious Activity Reports (SARs) and filed with the Bank of Botswana in accordance with applicable financial intelligence obligations.
Edge Functions Overview
53 Deno edge functions handle all business logic. They run on Supabase's global edge network and share common utilities from supabase/functions/_shared/.
| Category | Count | Functions |
|---|---|---|
| Zenus Banking | 13 | create-person, open-account, payment, cancel-payment, bulk-payment, card-issue, card-manage, card-pin, sync-balance, statement, compliance-check, ubo-declare, webhook |
| Travel — Duffel | 10 | airports, search, offer, book, orders, cancel, stays-search, stays-book, webhook |
| KYC — DIDIT | 3 | session, decision, webhook |
| Flutterwave | 5 | transfer, airtime, bills, payment-link |
| Africa's Talking | 4 | sms, whatsapp, airtime, ussd |
| File Storage | 4 | cf-upload, cf-file, cf-manage, cf-dns |
| HotelBeds | 3 | hotels, activities, transfers |
| Documenso | 3 | send, status, webhook |
| Core Platform | 5 | initiate-deposit, send-payment-request, payment-request-checkout, support-chat, ai-insights |
| AI / Tools | 3 | scan-receipt, auto-fill-business, nvidia-ai |
Banking
Olympus Pay is a regulated financial institution operating under the supervision of the Bank of Botswana Regulatory Sandbox. Cross-border payments, multi-currency accounts, and card issuance are delivered through our partnership with Zenus / TUUM — giving African SMBs access to global payment rails from a single platform.
Banking Structure
Payment Rails
| Rail | Use Case | Settlement | Currency |
|---|---|---|---|
LOCAL | Domestic EFT (Botswana) | Same day | BWP |
ACH | US bank transfers | 2-3 business days | USD |
SWIFT | International wire | 1-5 business days | Any |
BOOK_TRANSFER | Internal (Zenus to Zenus) | Instant | Any |
Card Lifecycle
Issue
zenus-card-issue — creates virtual or physical card, returns card_id.
Activate
zenus-card-pin — set a 4-digit PIN (required for physical cards).
Manage
zenus-card-manage — freeze, unfreeze, cancel, update limit, or request replacement.
Monitor
Zenus webhooks fire on authorization events and are processed by zenus-webhook.
Payments
Send Payment Request
The send-payment-request function creates a Flutterwave payment link and emails it to the recipient via Resend. A hosted payment page at olympuspay.co/pay-request?id=... is also generated for each request.
Bulk Payments (Payroll)
zenus-bulk-payment processes multiple recipients in a single batch call. Each recipient is processed individually and results are returned per-entry. The batch is recorded in the bulk_payments table.
Scheduled Payments
Scheduled payments are stored in scheduled_payments and triggered by a Supabase cron job (pg_cron) that runs daily at 06:00 UTC. The cron job calls the process_scheduled_payments RPC for any payments due that day.
KYC — DIDIT
Three functions handle the full identity verification lifecycle:
individual and business entity types. Business sessions also validate company name and type.profiles.kyc_status and inserts a record into kyc_verifications.Travel — Duffel
Flight search, booking, and management via Duffel API v2. All requests include the Duffel-Version: v2 header.
Flight Booking Flow
Search airports
GET /functions/v1/duffel-airports?q=gaborone — autocomplete IATA codes.
Search flights
POST /functions/v1/duffel-search — returns offers. Save offer_request_id and offer_id.
Get offer details
POST /functions/v1/duffel-offer — retrieves baggage, fare rules, and final price.
Book
POST /functions/v1/duffel-book with passenger details. Returns order_id and booking_reference.
Cancel (if needed)
POST /functions/v1/duffel-cancel with order_id. Refund processed per airline policy.
Messaging — Africa's Talking
| Function | Channel | Notes |
|---|---|---|
at-sms | SMS | Transactional payment notifications |
at-whatsapp | Template messages via AT WhatsApp API | |
at-airtime | Airtime | Airtime disbursement (alternative to Flutterwave) |
at-ussd | USSD | Stateful session. Authenticated via ?secret= query param. Returns CON (continue) or END responses. |
Storage — Cloudflare R2
file_type (avatar, receipt, doc, statement, invoice) and content_type.key.AI & Tools
BusinessProfile fields.Provider Integration Map
Webhooks
All inbound webhooks are verified with HMAC-SHA256 using secrets stored in Supabase Vault. Requests with invalid signatures return 403 Forbidden.
| Provider | Endpoint | Signature Header | Events |
|---|---|---|---|
| DIDIT | /functions/v1/didit-webhook | x-didit-signature | session.completed, session.failed |
| Zenus | /functions/v1/zenus-webhook | x-zenus-signature | PAYMENT_COMPLETED, CARD_FROZEN, COMPLIANCE_FLAGGED |
| Duffel | /functions/v1/duffel-webhook | x-duffel-signature | order.updated, order.cancelled |
| Documenso | /functions/v1/documenso-webhook | x-documenso-signature | document.signed, document.declined |
| Africa's Talking | /functions/v1/at-ussd | ?secret= query param | All USSD sessions |
Environment Variables
All secrets are managed via Supabase Vault and injected into edge functions at runtime.
Core (Required)
Banking
KYC
Payments & Messaging
Storage & Travel
Deploy Guide
Edge Functions
# Deploy all edge functions supabase functions deploy --no-verify-jwt zenus-webhook didit-webhook duffel-webhook documenso-webhook # Deploy a single function (most functions verify JWT inside the handler) supabase functions deploy zenus-payment # Set secrets (from .env file) supabase secrets set --env-file .env
Database Migrations
# Apply pending migrations supabase db push # Generate migration from schema changes supabase db diff --schema public -f migration_name
Web App (Vercel)
# Build Expo for web npx expo export --platform web # Deploy (from project root) vercel --prod
Local Development
# 1. Clone and install git clone git@github.com:olympuspay/olympus-app.git cd olympus-app npm install # 2. Start Supabase locally supabase start # 3. Copy environment cp .env.example .env # Fill in your API keys # 4. Start the app npx expo start # 5. Serve edge functions locally supabase functions serve
supabase start spins up local Postgres, GoTrue Auth, Kong, and the edge function runtime. Local URLs are printed on startup. Use the Supabase Studio at http://localhost:54323 to inspect data.
Regulatory
Olympus Pay (Botswana) is a regulated financial institution operating under the supervision of the Bank of Botswana Regulatory Sandbox. All platform operations, integrations, and data handling practices are designed and maintained in alignment with the obligations set forth under this regulatory framework.
Compliance Obligations
| Obligation | Scope |
|---|---|
| Identity Verification | Mandatory for all users prior to any financial transaction. Enforced at the database and application layers. |
| Ultimate Beneficial Owner (UBO) Declaration | Required for all business account holders. All beneficial owners holding 10% or greater equity must be declared before financial access is granted. |
| AML & Sanctions Screening | All users and beneficiaries are screened against OFAC, UN, EU, and UK sanctions registers, as well as PEP databases, at onboarding and on an ongoing basis. |
| Transaction Record Retention | All financial transaction records are retained for a minimum of seven years. |
| Suspicious Activity Reporting | High-severity compliance alerts are escalated and reported in accordance with applicable financial intelligence obligations. |