diff --git a/.env.example b/.env.example
index 17e2dea..fc18e3c 100644
--- a/.env.example
+++ b/.env.example
@@ -12,6 +12,14 @@ NOMBA_PARENT_ACCOUNT_ID=
NOMBA_CLIENT_ID=
NOMBA_CLIENT_SECRET=
+# Sandbox connectivity checks (POST /v1/sandbox/nomba/*) — always used
+# instead of the vars above, regardless of what those point at. Lets you
+# verify the Nomba integration works even when NOMBA_CLIENT_ID/SECRET above
+# have been switched to live credentials.
+NOMBA_SANDBOX_BASE_URL=https://sandbox.nomba.com
+NOMBA_TEST_CLIENT_ID=
+NOMBA_TEST_CLIENT_SECRET=
+
# Inbound webhook signature verification (Nomba → Somba)
WEBHOOK_SIGNING_SECRET=
diff --git a/README.md b/README.md
index 8c34c7b..a367ea4 100644
--- a/README.md
+++ b/README.md
@@ -44,12 +44,13 @@ It shows the entry points, the outbox, relay shards, event queues, workers, the
- [PRD.md](./PRD.md) describes the product in plain English
- [docs/](./docs/) contains the supporting documentation pages
-- [somba/](./somba/) is the placeholder Python package structure
-- [tests/](./tests/) is the placeholder test structure
-- [scripts/](./scripts/) contains developer helper stubs
+- [somba/](./somba/) is the FastAPI application: API routers, background workers (charge, recovery, reconciliation sweep, verify pass), the Nomba client, and the Alembic migrations
+- [tests/](./tests/) is the real test suite (unit + integration)
+- [scripts/](./scripts/) contains developer helper scripts (topic setup, demo seeding)
+- [frontend/](./frontend/) is the React + Vite docs site and landing page — includes the interactive API docs, a dashboard (email/password auth, named API keys), and a sandbox page for testing the Nomba integration without touching live credentials
-## Notes
+## Status
-This repository is still at scaffold stage. The current files are documentation and placeholders only, so the next step is implementation once the product shape is finalized.
+Somba is implemented and running. The API is live at `https://somba-jade.vercel.app`, backed by Postgres and a Redpanda-based outbox relay, with a full test suite (unit + integration) passing in CI. The docs above describe the design; the code is the current source of truth for exact request/response shapes.
> Somba by Team setld
diff --git a/frontend/index.html b/frontend/index.html
index d36f808..72da041 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,13 +4,42 @@
+
+
Somba — Recurring billing infrastructure for Nomba merchants
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
- Somba — Recurring billing infrastructure for Nomba merchants
diff --git a/frontend/public/og-image.png b/frontend/public/og-image.png
new file mode 100644
index 0000000..9e4e042
Binary files /dev/null and b/frontend/public/og-image.png differ
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 2b9429b..058d44f 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -3,6 +3,7 @@ import Landing from './pages/Landing'
import Signup from './pages/Signup'
import Login from './pages/Login'
import ApiKeys from './pages/ApiKeys'
+import Sandbox from './pages/Sandbox'
import DocsLayout from './layouts/DocsLayout'
import Introduction from './pages/docs/Introduction'
@@ -41,6 +42,7 @@ export default function App() {
} />
} />
} />
+ } />
}>
} />
diff --git a/frontend/src/components/ProfileMenu.jsx b/frontend/src/components/ProfileMenu.jsx
index 0947e53..a94c4f5 100644
--- a/frontend/src/components/ProfileMenu.jsx
+++ b/frontend/src/components/ProfileMenu.jsx
@@ -56,6 +56,13 @@ export default function ProfileMenu() {
>
API keys
+ setOpen(false)}
+ className="block px-4 py-2.5 font-mono text-[13px] text-text transition-colors hover:bg-panel-2"
+ >
+ Sandbox
+
+ {error}
+
+ )
+ }
+ if (result) {
+ return {JSON.stringify(result, null, 2)}
+ }
+ return null
+}
+
+async function hmacSha256Hex(secret, payload) {
+ const enc = new TextEncoder()
+ const key = await crypto.subtle.importKey(
+ 'raw',
+ enc.encode(secret),
+ { name: 'HMAC', hash: 'SHA-256' },
+ false,
+ ['sign'],
+ )
+ const sig = await crypto.subtle.sign('HMAC', key, enc.encode(payload))
+ return Array.from(new Uint8Array(sig))
+ .map((b) => b.toString(16).padStart(2, '0'))
+ .join('')
+}
+
+export default function Sandbox() {
+ const [apiKey, setApiKey] = useState('')
+ const [customerName, setCustomerName] = useState('Sandbox Test Customer')
+
+ const [authStatus, setAuthStatus] = useState('idle')
+ const [authResult, setAuthResult] = useState(null)
+ const [authError, setAuthError] = useState(null)
+
+ const [vaStatus, setVaStatus] = useState('idle')
+ const [vaResult, setVaResult] = useState(null)
+ const [vaError, setVaError] = useState(null)
+
+ const [whPayload, setWhPayload] = useState('{"type":"charge.succeeded","data":{"subscription_id":"sub_xxx"}}')
+ const [whSecret, setWhSecret] = useState('')
+ const [whSignature, setWhSignature] = useState('')
+ const [whResult, setWhResult] = useState(null)
+
+ async function onCheckAuth() {
+ setAuthStatus('loading')
+ setAuthError(null)
+ setAuthResult(null)
+ try {
+ const data = await sandboxCheckAuth(apiKey.trim())
+ setAuthResult(data)
+ } catch (err) {
+ setAuthError(err.message)
+ } finally {
+ setAuthStatus('idle')
+ }
+ }
+
+ async function onCreateVa() {
+ setVaStatus('loading')
+ setVaError(null)
+ setVaResult(null)
+ try {
+ const data = await sandboxCreateVirtualAccount(apiKey.trim(), customerName.trim())
+ setVaResult(data)
+ } catch (err) {
+ setVaError(err.message)
+ } finally {
+ setVaStatus('idle')
+ }
+ }
+
+ async function onVerifyWebhook() {
+ const expected = await hmacSha256Hex(whSecret, whPayload)
+ const matches = expected === whSignature.trim().toLowerCase()
+ setWhResult({ expected, matches })
+ }
+
+ return (
+
+
+
+
+
+
+ Sandbox
+
+
+ Test the Nomba integration
+
+
+ These calls always use dedicated sandbox credentials against{' '}
+ sandbox.nomba.com, regardless of
+ whether your account is holding live keys. Nothing here ever touches real money.
+
+
+
+ Your API key
+ setApiKey(e.target.value)}
+ placeholder="sk-somba-..."
+ className="rounded-sm border border-line bg-panel px-3 py-2 font-mono text-[13px] text-text outline-none focus-visible:border-settled"
+ />
+
+
+
+
+
+
Auth check
+
+ Confirms the sandbox credentials can issue a token.
+
+
+
+ {authResult ? 'ok' : authError ? 'failed' : 'untested'}
+
+
+
+ {authStatus === 'loading' ? 'Checking…' : 'Test auth'}
+
+
+
+
+
+
+
+
Virtual account
+
+ Creates a throwaway sandbox virtual account end-to-end.
+
+
+
+ {vaResult ? 'ok' : vaError ? 'failed' : 'untested'}
+
+
+
+
+ Nomba caps sandbox accounts at 2 virtual accounts total per account holder — once
+ that’s used up, this will fail with a real Nomba error even though nothing is
+ broken. That’s expected, not a bug.
+
+
+
+ Customer name
+ setCustomerName(e.target.value)}
+ className="rounded-sm border border-line bg-panel px-3 py-2 text-[14px] text-text outline-none focus-visible:border-settled"
+ />
+
+
+
+ {vaStatus === 'loading' ? 'Creating…' : 'Create test virtual account'}
+
+
+
+
+
+
+
+
Webhook signature
+
+ Checks a payload + secret against a signature entirely in your browser — no
+ Nomba call, no quota.
+
+
+ {whResult && (
+
+ {whResult.matches ? 'matches' : "doesn't match"}
+
+ )}
+
+
+
+ Payload (raw body)
+
+
+ Webhook secret
+ setWhSecret(e.target.value)}
+ className="rounded-sm border border-line bg-panel px-3 py-2 font-mono text-[13px] text-text outline-none focus-visible:border-settled"
+ />
+
+
+ Signature to check
+ setWhSignature(e.target.value)}
+ placeholder="hex digest from X-Somba-Signature"
+ className="rounded-sm border border-line bg-panel px-3 py-2 font-mono text-[13px] text-text outline-none focus-visible:border-settled"
+ />
+
+
+
+ Verify signature
+
+
+ {whResult && (
+
{whResult.expected}
+ )}
+
+
+
+
+ )
+}
diff --git a/somba/api/app.py b/somba/api/app.py
index 33ae430..c14a5e3 100644
--- a/somba/api/app.py
+++ b/somba/api/app.py
@@ -18,6 +18,7 @@
from somba.api.middleware.auth import get_current_merchant
from somba.api.middleware.idempotency import IdempotencyMiddleware
from somba.api.plans import router as plans_router
+from somba.api.sandbox import router as sandbox_router
from somba.api.subscriptions import router as subscriptions_router
from somba.api.webhooks import router as webhooks_router
from somba.db.models import Merchant
@@ -39,6 +40,7 @@
app.include_router(invoices_router)
app.include_router(events_router)
app.include_router(metrics_router)
+app.include_router(sandbox_router)
@app.on_event("startup")
diff --git a/somba/api/sandbox.py b/somba/api/sandbox.py
new file mode 100644
index 0000000..d7da842
--- /dev/null
+++ b/somba/api/sandbox.py
@@ -0,0 +1,72 @@
+"""Sandbox connectivity checks against Nomba — always uses the dedicated
+TEST credentials and the sandbox host, regardless of what NOMBA_CLIENT_ID /
+NOMBA_API_BASE_URL are currently set to in the main environment. Safe to hit
+even when the account is holding live credentials.
+"""
+
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel, Field
+from sqlalchemy.orm import Session
+
+from somba.api.errors import APIError
+from somba.api.middleware.auth import get_current_merchant
+from somba.db.models import Merchant
+from somba.db.session import get_db
+from somba.nomba.sandbox_client import (
+ SandboxNombaError,
+ sandbox_create_virtual_account,
+ sandbox_issue_token,
+)
+
+router = APIRouter(prefix="/v1/sandbox/nomba", tags=["sandbox"])
+
+
+def _raise_from(exc: SandboxNombaError) -> None:
+ raise APIError(
+ code="sandbox_nomba_error",
+ message=str(exc),
+ status_code=502,
+ ) from exc
+
+
+@router.post("/auth")
+def check_auth(
+ db: Session = Depends(get_db),
+ merchant: Merchant = Depends(get_current_merchant),
+) -> dict:
+ """Confirm the sandbox TEST credentials can issue a token."""
+
+ try:
+ sandbox_issue_token()
+ except SandboxNombaError as exc:
+ _raise_from(exc)
+ return {"status": "ok", "environment": "sandbox"}
+
+
+class SandboxVirtualAccountRequest(BaseModel):
+ customer_name: str = Field(min_length=1, max_length=255)
+
+
+@router.post("/virtual-account")
+def check_virtual_account(
+ body: SandboxVirtualAccountRequest,
+ db: Session = Depends(get_db),
+ merchant: Merchant = Depends(get_current_merchant),
+) -> dict:
+ """Create a throwaway virtual account in Nomba's sandbox to confirm the
+ full auth + resource-call path works. Never touches live credentials."""
+
+ try:
+ va = sandbox_create_virtual_account(customer_name=body.customer_name)
+ except SandboxNombaError as exc:
+ _raise_from(exc)
+ return {
+ "status": "ok",
+ "environment": "sandbox",
+ "account_number": va.account_number,
+ "bank_name": va.bank_name,
+ "account_holder_id": va.account_holder_id,
+ "account_ref": va.account_ref,
+ }
diff --git a/somba/nomba/sandbox_client.py b/somba/nomba/sandbox_client.py
new file mode 100644
index 0000000..0a0e182
--- /dev/null
+++ b/somba/nomba/sandbox_client.py
@@ -0,0 +1,149 @@
+"""A deliberately isolated Nomba client for the sandbox test endpoints.
+
+somba.nomba.client caches its auth token in module-level state and reads
+credentials from the main NOMBA_CLIENT_ID / NOMBA_CLIENT_SECRET / base URL —
+whichever environment (live or sandbox) those happen to point at. Reusing it
+here would mean a sandbox test could silently overwrite the cached token a
+concurrent real request relies on, or vice versa: a genuine risk once the
+account is holding live credentials, which is the whole reason this module
+exists.
+
+This module never touches that shared state. It always uses its own
+dedicated NOMBA_TEST_CLIENT_ID / NOMBA_TEST_CLIENT_SECRET credentials against
+NOMBA_SANDBOX_BASE_URL (defaulting to https://sandbox.nomba.com), issues a
+fresh token per call, and shares nothing with the production path.
+"""
+
+from __future__ import annotations
+
+import os
+import uuid
+from dataclasses import dataclass
+
+
+class SandboxNombaError(Exception):
+ """Raised when a sandbox call to Nomba fails."""
+
+ def __init__(self, message: str, status_code: int | None = None, body: str | None = None):
+ super().__init__(message)
+ self.status_code = status_code
+ self.body = body
+
+
+def _describe_error(resp) -> str:
+ """Pull Nomba's own error description out of the response, if present."""
+
+ try:
+ data = resp.json()
+ return data.get("description") or data.get("message") or resp.text
+ except Exception:
+ return resp.text
+
+
+def _payload_or_raise(resp, action: str) -> dict:
+ """Nomba sometimes returns HTTP 200 with a body that still signals
+ failure (no "data") instead of a 4xx/5xx — so an HTTP-status-only check
+ isn't enough. Check both.
+
+ Nomba's own "status" boolean is not reliable as a success signal — the
+ token-issue endpoint sends "status": false even on a genuine success
+ (code "00", "Successful", a populated "data"). The one consistent
+ signal across endpoints is whether "data" is present, so that's what
+ this checks instead.
+ """
+
+ try:
+ parsed = resp.json()
+ except Exception:
+ parsed = None
+
+ is_body_error = not isinstance(parsed, dict) or "data" not in parsed
+ if resp.is_error or is_body_error:
+ description = _describe_error(resp) if parsed is None else (
+ parsed.get("description") or parsed.get("message") or resp.text
+ )
+ raise SandboxNombaError(f"{action}: {description}", resp.status_code, resp.text)
+ return parsed["data"]
+
+
+def _sandbox_base_url() -> str:
+ return os.environ.get("NOMBA_SANDBOX_BASE_URL", "https://sandbox.nomba.com")
+
+
+def _test_client_id() -> str:
+ return os.environ.get("NOMBA_TEST_CLIENT_ID", "")
+
+
+def _test_client_secret() -> str:
+ return os.environ.get("NOMBA_TEST_CLIENT_SECRET", "")
+
+
+def _parent_account_id() -> str:
+ return os.environ.get("NOMBA_PARENT_ACCOUNT_ID") or os.environ.get("NOMBA_ACCOUNT_ID", "")
+
+
+def _sub_account_id() -> str:
+ return os.environ.get("NOMBA_ACCOUNT_ID", "")
+
+
+def sandbox_issue_token() -> str:
+ """Issue a fresh sandbox access token. Never cached, never shared."""
+
+ import httpx
+
+ base_url = _sandbox_base_url()
+ client_id = _test_client_id()
+ client_secret = _test_client_secret()
+
+ if not client_id or not client_secret:
+ raise SandboxNombaError(
+ "NOMBA_TEST_CLIENT_ID / NOMBA_TEST_CLIENT_SECRET are not configured"
+ )
+
+ with httpx.Client(timeout=15) as client:
+ resp = client.post(
+ f"{base_url}/v1/auth/token/issue",
+ json={"grant_type": "client_credentials", "client_id": client_id, "client_secret": client_secret},
+ headers={"accountId": _parent_account_id(), "Content-Type": "application/json"},
+ )
+ data = _payload_or_raise(resp, "Sandbox auth failed")
+ return data["access_token"]
+
+
+@dataclass
+class SandboxVirtualAccountResult:
+ account_number: str
+ bank_name: str
+ account_holder_id: str
+ account_ref: str
+
+
+def sandbox_create_virtual_account(*, customer_name: str) -> SandboxVirtualAccountResult:
+ """Create a virtual account against the sandbox environment only."""
+
+ import httpx
+
+ base_url = _sandbox_base_url()
+ token = sandbox_issue_token()
+ sub_account_id = _sub_account_id()
+ account_ref = f"sandbox-va-{uuid.uuid4().hex}"
+
+ headers = {
+ "Authorization": f"Bearer {token}",
+ "accountId": _parent_account_id(),
+ "Content-Type": "application/json",
+ }
+
+ with httpx.Client(timeout=30) as client:
+ resp = client.post(
+ f"{base_url}/v1/accounts/virtual/{sub_account_id}",
+ json={"accountRef": account_ref, "accountName": customer_name},
+ headers=headers,
+ )
+ data = _payload_or_raise(resp, "Sandbox virtual account creation failed")
+ return SandboxVirtualAccountResult(
+ account_number=data["bankAccountNumber"],
+ bank_name=data.get("bankName", ""),
+ account_holder_id=data.get("accountHolderId", ""),
+ account_ref=data.get("accountRef", account_ref),
+ )