Bidbox platform: how it works

A short guide to what the system does today, who can do what, and how money moves. Written for the owner and the team, not for developers only.

What it is

Bidbox sells identity and business checks to banks and Saccos. Organisations sign in to a dashboard, run checks, and pay for each one from a prepaid credit wallet. Resellers buy credit from Bidbox and sell checks to their own clients at their own price.

Today every check returns a sample result from the sandbox (fixed test answers). Results are marked "Sample result" on screen and stored with sample = true. No agency is called yet.

The checks

Check Agency Asks for Sample base price
KYC ID check by serial number Citizen KYC ID number, ID serial number KES 20
KYC ID check against phone number Citizen KYC ID number, phone number KES 20
BRS business check BRS Registration number KES 50
NTSA vehicle check NTSA Number plate KES 50
KRA PIN check KRA KRA PIN KES 30

The base prices are sample prices. The super admin changes them on the Pricing page.

Every check needs a purpose (account opening, loan application, Sacco member registration and others) and a consent tick. Both are stored with the check. ID numbers, serial numbers, phone numbers and personal names are masked in history; the full sample answer is shown once, to the person who ran the check.

Sandbox test inputs

Check Input Gives
KYC serial ID 12345678, serial 123456789 Match
KYC serial ID 12345678, any other serial No match
KYC serial ID 00000000 Not found
KYC phone ID 12345678, phone 0712 345 678 Match
BRS PVT-ABC123 / PVT-STRUCK1 / PVT-000000 Registered / struck off / not found
NTSA KDA 123A / KCZ 999Z / KAA 000A Clean / caveat on record / not found
KRA A012345678Z / P051234567X / A000000000A Individual / company / not found

Any other valid input gets a stable made-up answer (the same input always gives the same answer). A "not found" check is still charged.

Modules

Module Screen What it does
Overview /dashboard Balance and how many checks it covers, checks per day, latest checks, top-ups waiting for you, low-balance clients, recent activity
Checks /dashboard/check, /dashboard/history Run a check; history with filters, masked details and CSV export; super admin can refund a check
Credit management /dashboard/credit Wallet balance, statement (every ledger entry), CSV, monthly statement CSV with opening and closing balance; super admin adds or removes credit with a reason
Transfers /dashboard/transfers Super admin sends credit to any reseller or client; a reseller sends credit to its own clients
Top-ups /dashboard/topups Request a top-up ("I paid KES X, reference Y"); approve or reject queue; CSV receipt for each approved top-up
Clients /dashboard/clients Super admin: every organisation, add clients and resellers, suspend, restore, make a client a reseller, custom prices. Reseller: its own clients
Pricing /dashboard/pricing Super admin sets base prices; reseller sets selling prices; clients see their own prices
Pull reports /dashboard/reports Checks, spend, reseller margin and Bidbox revenue by date range, organisation and check; CSV
User management /dashboard/users Add users with a role, change roles, deactivate, reset passwords, unlock locked accounts, reset two-step sign-in; permission table
Settings /dashboard/settings General: profile, password, appearance (System, Light or Dark theme), organisation details. Security: checklist, two-step sign-in, where you are signed in, and the organisation's security rules
Audit log /dashboard/audit Every sensitive action, who did it and when; CSV
API keys /dashboard/keys Keys for an organisation's own systems, with scopes; webhook address and signing secret
Security review /dashboard/security Bidbox staff: two-step sign-in coverage per organisation, locked accounts, failed sign-ins in the last 7 days; change any organisation's rules
Notifications Bell in the top bar Top-up decisions, credit received, low credit, security events
Ask Bidbox Search (Ctrl+K) and Ask (Ctrl+J) in the top bar Search pages, clients, checks, top-ups and this guide; answer how-to questions; prepare actions for you to confirm

Organisations

  • Bidbox (platform): the super admin and Bidbox staff. Sees and manages everything.
  • Reseller: buys credit from Bidbox, owns clients, sets its own selling prices.
  • Client: a bank, Sacco or other business that runs checks. A client belongs to Bidbox directly or to one reseller.

The super admin can make a direct client a reseller. It keeps its wallet, users and history, and can then add clients and set prices. (A client that belongs to a reseller cannot be converted, so a reseller never loses a client without a decision about who owns it.)

Suspending an organisation signs its users out and blocks sign-in, checks and incoming credit until it is restored. Clients of a suspended reseller cannot run checks either.

Roles and permissions

Each person belongs to one organisation and has one role. What they can do is the role's permissions, limited by what their kind of organisation can do. The table lives in lib/permissions.ts and is enforced on the server for every API route; the dashboard hides what a person cannot do.

Permission Owner Admin Operator Finance Viewer
Run checks (clients, resellers) yes yes yes
See check history yes yes yes yes yes
Refund a check (Bidbox only) yes yes
See credit and statement yes yes yes yes yes
Transfer credit (Bidbox, resellers) yes yes yes
Add or remove credit by hand (Bidbox only) yes yes
Request a top-up (clients, resellers) yes yes yes
Approve or reject top-ups (Bidbox, resellers) yes yes yes
See clients (Bidbox, resellers) yes yes yes yes
Add, suspend, convert clients (Bidbox, resellers) yes yes
See prices yes yes yes yes yes
Set prices (Bidbox, resellers) yes yes
See users yes yes yes
Manage users yes yes
Pull reports yes yes yes yes
Read the audit log yes yes yes
Edit organisation details and security rules yes yes
Manage API keys and webhooks (clients, resellers) yes yes

Owner and Admin differ in one way: only an owner can add, change or remove another owner. Every organisation keeps at least one active owner. Nobody can change their own role or deactivate themselves.

Clients can never transfer credit. Only Bidbox (to anyone) and resellers (to their own clients) can.

What each organisation sees:

  • Bidbox staff: every organisation.
  • Reseller users: their own organisation and their own clients.
  • Client users: their own organisation only.

Money

All amounts are Kenya shillings, stored in cents.

Wallets and the ledger. Every organisation has one wallet. Its balance only changes by writing a ledger entry, inside a database transaction that locks the wallet row. The ledger is append-only: the database refuses to edit or delete an entry, refuses a balance below zero, and refuses a balance change that does not match the latest entry. Entry types: topup, adjustment, transfer_in, transfer_out, check_charge, margin_credit, refund.

Where credit comes from. Bidbox issues credit. When a payment reaches Bidbox, the super admin approves the top-up request (or sends a transfer, or adds an adjustment with a reason). The Bidbox wallet itself is not debited: Bidbox is the issuer, and its revenue is reported from the checks, not held in a wallet.

Reseller credit. A reseller buys credit from Bidbox at face value (KES 10,000 paid gives KES 10,000 of credit). It passes credit to its clients by a transfer, or by approving a client's top-up request, which moves the amount from the reseller's wallet to the client's.

What a check costs.

Who runs it Charged Reseller gets Bidbox keeps
Direct client Base price (or a custom price Bidbox set for that client) All of it
A reseller's client The reseller's price for that client, else its default selling price, else the base price Selling price minus base, as margin_credit The base price
A reseller, for itself Base price (or a custom price Bidbox set for it) All of it

Example with the sample prices: Pwani Data Partners sells the KYC serial check at KES 25 (KES 23 for Mombasa Teachers Sacco). When the Sacco runs one, the Sacco's wallet goes down KES 23 and Pwani's goes up KES 3. CI checks this on every push.

A reseller cannot set a selling price below the base price. If Bidbox later raises a base price above a reseller's price, the base price is charged and the margin is zero until the reseller updates its price.

Why this model holds. The reseller paid face value for the credit it gave its client, so on each check the client's KES 23 of credit is used up: KES 20 is Bidbox's service and KES 3 returns to the reseller as margin. The reseller's margin arrives as credit in its wallet. It can use it for its own checks or pass it to clients. Paying margin out in cash is not built; if that is wanted later, it is a payout entry plus a bank or M-Pesa transfer.

Refunds. Bidbox staff can refund a check with a reason: the client gets the price back and, for a reseller's client, the reseller's margin is taken back.

Top-ups. A client or reseller requests a top-up after paying ("KES 10,000, reference QK12AB34CD"). The request goes to whoever sells them credit: Bidbox for resellers and direct clients, the reseller for its own clients. The approver checks the payment and approves (credit lands) or rejects with a reason. A reference can only be used once per organisation. Requests are stored with method = 'manual'; M-Pesa confirmation can be added later as method = 'mpesa' that approves itself when Safaricom confirms the payment.

Sign-in

  1. Email or phone number, plus password.
  2. A 6-digit code, every time. It is sent by SMS (Africa's Talking) when you sign in with your phone, by email (SMTP) when you sign in with your email, and falls back to the other if one is not set up. The code lasts 5 minutes, allows 5 tries, and can be re-sent 3 times. Only a hash of the code is stored.

People added by an owner get a temporary password, shown once to the person who added them, and choose their own at first sign-in.

Test mode (TEST_MODE=1, for the test domain): for test accounts only, the code is also shown on the sign-in screen in a marked box. Never set TEST_MODE on the live domain.

  1. Two-step sign-in (optional, or required by the organisation). People who turn it on in Settings, Security also enter the 6-digit code from an authenticator app (Google Authenticator, Microsoft Authenticator, Authy). Turning it on shows 10 one-time recovery codes, once. A recovery code works in place of the app code, one time each. The app secret is stored encrypted; recovery codes only as hashes. An owner or admin can require it for the whole organisation: people without it can then only open Settings, Security until they set it up. If someone loses their phone and their codes, an owner (or Bidbox) can reset it from Users and roles.

Passwords. At least 10 characters, not on the built-in list of common passwords (checked with digits and symbols at the ends removed, so "Password2026!" fails), no runs like 123456 or qwerty, and not containing the person's name, email or organisation name. No outside service is called.

Lockout. After 5 wrong passwords the account locks for 1 minute, then 2, 4, 8 and so on, up to 60 minutes per further wrong try. The right password does not work while it is locked. An owner or Bidbox can unlock it from Users and roles or the Security review page. Locks and wrong passwords go to the audit log.

Sessions. Settings, Security lists every place you are signed in (device and browser, IP address, when it started, last activity). Sign out one, or all others. Each organisation sets an idle timeout (5 minutes to 12 hours, default 1 hour); a session idle for longer ends.

Sign-in alerts. The first time someone signs in from a device Bidbox has not seen for them, it is recorded in the audit log, shown in their notifications, and sent by email or SMS when those are set up. Their very first device is only recorded.

IP allowlist (optional). An owner or admin can list the office IP addresses or ranges (like 197.248.10.0/24) the organisation may use. Sign-in, every dashboard request and every API key call from anywhere else is refused and logged. You cannot save a list that leaves out your own current address. Bidbox staff can change or clear any organisation's list from the Security review page (the override). Behind nginx, set TRUST_PROXY=1 so the real client address is used; without it the X-Forwarded-For header is ignored, so it cannot be forged.

Other protections: scrypt password hashing; a signed, httpOnly, SameSite=Lax session cookie (Secure in production) that lasts 12 hours and is ended at once by signing out, a password change, a reset or deactivation; rate limits on sign-in, codes, the assistant and API keys; changes only accepted as same-site JSON requests; parameterised SQL everywhere; server-side checks on every input.

Ask Bidbox (assistant)

Two tools in the top bar of the dashboard.

Search (Ctrl+K). A command palette. It finds pages you can open, your clients, recent checks, top-ups and sections of this guide. It works without any AI model.

Ask (Ctrl+J). A panel that:

  • answers how-to questions from this guide;
  • answers questions about your own figures (balance, checks and spend in the last 7 days, top-ups waiting);
  • opens pages: "take me to transfers";
  • prepares actions: "send 5,000 to Coast Microfinance", "approve top-up QKTEST0001", "invite Jane Achieng as operator", "set kyc serial price for Mombasa Teachers Sacco to 24".

How it stays safe:

  • Nothing runs by itself. A prepared action is a card with the details and a Confirm button. Pressing Confirm calls the same API route as the normal page, so every permission and money check applies again. The audit log marks it "confirmed in Ask Bidbox".
  • Your permissions only. The assistant can only look up and prepare what your role allows. A client cannot even prepare a transfer.
  • No personal data to the model. Before any text is sent, email addresses, phone numbers, ID and serial numbers, KRA PINs and long digit runs are replaced with placeholders. Lookups return only organisation names, amounts and counts. Contact details for an invite are typed on the card, never sent to the model.
  • Plain commands need no model. Commands like the ones above are understood by the server directly. Questions go to the language model only when one is configured.

The model is any OpenAI-compatible chat API; Groq is the default. Without a key the panel shows "Assistant not configured", and Search, commands and guide answers still work. Each question is logged in the audit log (not its text).

API and webhooks

Owners and admins of clients and resellers make API keys on the API keys page. A key is shown once; Bidbox keeps only a fingerprint. Each key has scopes: run checks, read check history, read balance. The organisation's IP allowlist applies to keys.

curl https://<site>/api/v1/balance -H "Authorization: Bearer bbx_live_..."
curl https://<site>/api/v1/checks -H "Authorization: Bearer bbx_live_..." \
  -H "Content-Type: application/json" \
  -d '{"product":"kra_pin","inputs":{"pin":"A012345678Z"},"purpose":"account_opening","consent":true}'

Checks over the API are priced and charged exactly like the dashboard. Up to 120 calls a minute per key.

Webhooks. Set an https address on the API keys page. Bidbox POSTs check.completed and topup.decided events as JSON, signed with the organisation's secret: X-Bidbox-Signature: sha256=<HMAC-SHA256 of the body>. One try, 5-second timeout. Local and private addresses are refused.

Notifications and alerts

The bell in the top bar lists top-up decisions, credit received, low credit and security events (new device, lockout, two-step sign-in turned off, recovery code used). In Settings, Security an owner or admin sets a low-credit level; when a payment takes the wallet below it, owners, admins and finance staff get a notification and owners an email or SMS.

Test accounts

The test site is https://bidbox.185.205.210.66.sslip.io. Sign in with the email (or phone) and password, then the 6-digit code. In test mode the code is shown on the sign-in screen. Every check on the test site returns sample records, not real ones.

Email Organisation Kind Role
superadmin@bidbox.test Bidbox platform (super admin) owner
reseller@bidbox.test Pwani Data Partners reseller owner
reseller.operator@bidbox.test Pwani Data Partners reseller operator
sacco@bidbox.test Mombasa Teachers Sacco client of the reseller owner
microfinance@bidbox.test Coast Microfinance client of the reseller owner
bank@bidbox.test Harambee Bank (test) direct client owner
bank.finance@bidbox.test Harambee Bank (test) direct client finance
bank.viewer@bidbox.test Harambee Bank (test) direct client viewer

Passwords are shared privately by the Bidbox team. The test data also has opening credit, sample checks, transfers, a top-up waiting for the reseller, one waiting for Bidbox and one rejected top-up.

scripts/seed-test.mjs creates this data on an empty database (with example.test emails such as pwani.owner@example.test; on the test site they were renamed to the simple addresses above). Passwords are random and written only to test-accounts.local.md in the repo root, which git ignores. The script prints nothing secret and does nothing if the data already exists.

Setup

Needs Node 20+ and PostgreSQL 13+ (16 in CI).

npm ci
cp .env.example .env        # fill in DATABASE_URL and SESSION_SECRET
node scripts/migrate.mjs --env .env
node scripts/seed-test.mjs --env .env    # test domain only
npm run build

Environment

Variable Needed What it does
DATABASE_URL yes PostgreSQL connection
SESSION_SECRET yes 32+ random characters; signs sessions and hashes codes
NEXT_PUBLIC_SITE_URL yes Public address (also used by the same-site check)
APP_URL no Address used in invite messages, if different
TEST_MODE test domain 1 shows codes for test accounts on the sign-in screen
CHECK_PROVIDER no sandbox (default, the only one today)
SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASS, SMTP_FROM for email codes Any SMTP server
AT_USERNAME, AT_API_KEY, AT_SENDER_ID for SMS codes Africa's Talking (sandbox username uses their sandbox)
TEST_EMAIL_DOMAIN no Domain for seeded test emails
TRUST_PROXY behind nginx 1 to read the client address from X-Forwarded-For (IP allowlist, rate limits)
GROQ_API_KEY or ASSISTANT_API_KEY for Ask Bidbox answers Key for the language model; without it only search, commands and guide answers work
ASSISTANT_BASE_URL no Any OpenAI-compatible API (default https://api.groq.com/openai/v1)
ASSISTANT_MODEL, ASSISTANT_FALLBACK_MODEL no Default llama-3.3-70b-versatile, fallback llama-3.1-8b-instant
ASSISTANT_TIMEOUT_MS no Default 12000
PORT, HOSTNAME no Where the server listens (default 3000, all interfaces)
SITE_ENV live only production at build time lets search engines index the real domain

Without any SMS or email settings, production refuses to sign people in (it cannot send codes), except test accounts in test mode.

Migrations

Plain SQL files in db/migrations, applied in name order by scripts/migrate.mjs and tracked in the schema_migrations table. Safe to run on every deploy. Add a new numbered file for every change; never edit one that has run. --status lists applied and pending files.

Deploy (Linux VPS)

The app builds to a standalone Node server (output: 'standalone'). Build on the server or in CI, then copy the result.

# on the server, in /srv/bidbox (a git checkout)
git pull
npm ci
npm run build
cp -R .next/static .next/standalone/.next/static
cp -R public .next/standalone/public
node scripts/migrate.mjs --env /etc/bidbox.env

Run it with pm2:

set -a; . /etc/bidbox.env; set +a
PORT=3010 HOSTNAME=127.0.0.1 pm2 start .next/standalone/server.js --name bidbox --update-env
pm2 save

Or with systemd (/etc/systemd/system/bidbox.service):

[Unit]
Description=Bidbox
After=network.target postgresql.service

[Service]
WorkingDirectory=/srv/bidbox/.next/standalone
EnvironmentFile=/etc/bidbox.env
Environment=NODE_ENV=production PORT=3010 HOSTNAME=127.0.0.1
ExecStart=/usr/bin/node server.js
Restart=always
User=bidbox

[Install]
WantedBy=multi-user.target

nginx in front, with TLS (certbot):

server {
  server_name test.example.com;
  location / {
    proxy_pass http://127.0.0.1:3010;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $remote_addr;   # with TRUST_PROXY=1
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host $host;
  }
  listen 443 ssl;
  # ssl_certificate lines added by certbot
}

Pick a port the IventsPro apps on the same server do not use. Set X-Forwarded-For to $remote_addr (not appended), because rate limits read the first address. Health check: GET /api/health. Back up the database daily (pg_dump).

Continuous integration

.github/workflows/ci.yml runs on every push: install, typecheck, build, then a throwaway PostgreSQL 16 with migrations and the test seed, the standalone server in test mode, and scripts/smoke.mjs. The smoke test signs in as every test role with the OTP, runs checks as a reseller's client and checks the ledger math (client charged the reseller's price, reseller credited the margin), confirms clients cannot transfer (403), approves top-ups, pulls report CSVs, checks every dashboard page renders, and confirms the ledger cannot be edited.

Adding a real check provider later

Checks go through lib/server/checks/provider.ts. A provider has a name and a run(product, input) that returns an outcome, a one-line summary, the result fields (with masked versions for history) and a reference. To connect Gava Connect or an agency: write an adapter with that shape, add it in getProvider(), put its credentials in the environment, and set CHECK_PROVIDER. A live adapter should place a hold on the wallet before calling out, and never charge for a failed call.

Not built yet

  • M-Pesa (Daraja) top-ups: the request and approval flow is ready for it.
  • PDF receipts, invoices and VAT (CSV receipts and monthly statements exist).
  • Webhook retries and a delivery log.
  • Paying reseller margin out in cash.
  • Live agency connections (see above).