diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..89cd654
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,26 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+context
\ No newline at end of file
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..a36934d
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,16 @@
+# React + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
+
+## React Compiler
+
+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 0000000..ea36dd3
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -0,0 +1,21 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{js,jsx}'],
+ extends: [
+ js.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ globals: globals.browser,
+ parserOptions: { ecmaFeatures: { jsx: true } },
+ },
+ },
+])
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..d36f808
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,19 @@
+
+
+
+ Keys you mint here are what your own code uses to call the Somba API. Name them by
+ what they’re for — production, local development, a specific integration —
+ so revoking one is never a guess.
+
+
+ {revealedKey && (
+
+
+ Copy your API key now — it will never be shown again.
+
+ {revealedKey}
+
+ )}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {keys === null && (
+
Loading your keys…
+ )}
+
+ {keys?.length === 0 && (
+
+ No keys yet. Create one above to use in your code.
+
+ )}
+
+ {keys?.length > 0 && (
+
+
+
+
+
+ Name
+
+
+ Key
+
+
+ Created
+
+
+ Last used
+
+
+
+
+
+ {keys.map((k) => (
+
+
{k.name}
+
+ sk-somba-{k.key_id}…
+
+
+ {new Date(k.created_at).toLocaleDateString()}
+
+
+ {k.last_used_at
+ ? new Date(k.last_used_at).toLocaleDateString()
+ : 'never'}
+
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/Landing.jsx b/frontend/src/pages/Landing.jsx
new file mode 100644
index 0000000..bcab158
--- /dev/null
+++ b/frontend/src/pages/Landing.jsx
@@ -0,0 +1,242 @@
+import { Link } from 'react-router-dom'
+import { FaArrowRotateRight, FaCalendarDays, FaGithub, FaScaleBalanced } from 'react-icons/fa6'
+import SiteNav from '../components/SiteNav'
+import SiteFooter from '../components/SiteFooter'
+import CodeBlock from '../components/CodeBlock'
+import LedgerStrip from '../components/LedgerStrip'
+import AmbientBackground from '../components/AmbientBackground'
+import Eyebrow from '../components/Eyebrow'
+
+const glass =
+ 'rounded-2xl border border-line/70 bg-panel/50 backdrop-blur-md shadow-[inset_0_1px_0_0_rgba(237,239,234,0.06),0_30px_60px_-20px_rgba(0,0,0,0.75)]'
+
+const features = [
+ {
+ icon: FaCalendarDays,
+ title: 'Bills on schedule',
+ body: 'Define a plan. Subscribe a customer. Somba handles the rest automatically.',
+ },
+ {
+ icon: FaArrowRotateRight,
+ title: 'Recovers failures',
+ body: 'Classifies every failed charge and routes it to the right recovery path automatically.',
+ },
+ {
+ icon: FaScaleBalanced,
+ title: 'Proves every naira',
+ body: 'Full ledger of intents and settlements. Every charge accounted for.',
+ },
+]
+
+const steps = [
+ {
+ n: '01',
+ title: 'Get your API key',
+ body: 'Create an account. Mint a key from your dashboard, shown once.',
+ },
+ {
+ n: '02',
+ title: 'Create a plan and subscribe a customer',
+ body: 'POST /v1/plans then POST /v1/subscriptions. Somba starts billing on the cycle you set.',
+ },
+ {
+ n: '03',
+ title: 'Listen to webhooks',
+ body: 'Somba signs every event with your webhook secret. React to charge.succeeded, charge.failed, subscription.past_due, and more.',
+ },
+]
+
+const curlExample = `curl -X POST https://somba.ddns.net/v1/subscriptions \\
+ -H "Authorization: Bearer sk-somba-." \\
+ -H "Idempotency-Key: sub-kemi-001" \\
+ -H "Content-Type: application/json" \\
+ -d '{
+ "customer_id": "cus_xxx",
+ "plan_id": "plan_xxx"
+ }'`
+
+const webhookExample = `{
+ "type": "charge.succeeded",
+ "data": {
+ "subscription_id": "sub_xxx",
+ "amount": 1500000
+ }
+}`
+
+const recoveredExample = `{
+ "type": "charge.recovered",
+ "data": {
+ "subscription_id": "sub_xxx",
+ "recovery_path": "timing"
+ }
+}`
+
+const recoveryPaths = [
+ ['empty_account', 'Retry later, when funds are more likely present'],
+ ['broken_card', 'Stop pulling, switch to transfer fallback'],
+ ['transient', 'Retry once, then decide'],
+ ['risk', 'Stop — do not keep pushing'],
+]
+
+export default function Landing() {
+ return (
+
+
+
+
+
+
+
+
+ Nomba × DevCareer Hackathon 2026
+
+
+ Recurring billing infrastructure for Nomba merchants.
+
+
+
+ Add subscriptions to your product in an afternoon. Somba handles the billing,
+ recovery, and reconciliation. You handle your product.
+
+ Somba classifies every failure and picks the next step itself — retry at a
+ better time, switch to transfer fallback, or stop if the payment looks unsafe.
+ You just listen for the webhook.
+
+
+ {recoveryPaths.map(([code, meaning]) => (
+
+ {code}
+ {meaning}
+
+ ))}
+
+
+
+ {recoveredExample}
+
+
+
+
+
+
+
+ Get started
+
+ Add billing to your product this afternoon.
+
+
+ Create an account, mint a key, and make your first request. No sales call required.
+
+ Your email and password get you into the dashboard. You mint your API key from there
+ — it’s a separate credential you use in your own code.
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/pages/docs/Authentication.jsx b/frontend/src/pages/docs/Authentication.jsx
new file mode 100644
index 0000000..96c3c98
--- /dev/null
+++ b/frontend/src/pages/docs/Authentication.jsx
@@ -0,0 +1,79 @@
+import { Link } from 'react-router-dom'
+import DocsPage from '../../components/DocsPage'
+import CodeBlock from '../../components/CodeBlock'
+
+export default function Authentication() {
+ return (
+
+
+ You’ll need an API key before any of these requests will work.{' '}
+
+ Click here
+ {' '}
+ to create an account and mint one.
+
+
+
+ Your API key has two parts: a key_id, which Somba uses to look up your
+ merchant, and a secret, which is checked against the bcrypt hash Somba stores. Only the
+ hash is ever kept — Somba cannot show you the raw secret again after it’s issued.
+
+
+
+ Pass the key on every request as a bearer token. It’s the only credential your code
+ needs — there’s no session to manage on the API side. The key itself is minted from
+ your dashboard, which you get into with your email and password.
+
+ Don’t have a key yet?{' '}
+
+ Create an account
+ {' '}
+ to get into your dashboard, or{' '}
+
+ log in
+ {' '}
+ if you already have one, then mint a key from there.
+
+
+
+ Your key is shown to you exactly once, when you mint it. If you lose it, generate a new
+ one from the dashboard — there is no recovery flow for a lost secret, by design.
+
+
+
+
A missing or invalid key
+
+ A request with no bearer token is rejected with unauthorized. A request
+ with a token that doesn’t match any merchant is rejected with{' '}
+ invalid_api_key. Both happen before anything else runs.
+
+ A customer record ties a billing identity to your own user. It also holds the payment
+ token reference and, once assigned, a dedicated virtual account for transfer recovery.
+
+
+
external_id
+
+ Set external_id to your own user ID at creation time. Every lookup — from a
+ webhook payload, from a support ticket, from your own dashboard — can then resolve back
+ to a customer by the identity your system already uses, without keeping a second mapping
+ table.
+
+
+
The token key
+
+ Somba stores a reference to the customer’s payment method, never the raw card number.
+ Charges are made by asking Nomba to use the stored token — the card details themselves
+ never pass through or live in Somba.
+
+
+
The virtual account
+
+ va_id and va_account_no are assigned automatically the first time
+ transfer fallback recovery fires for this customer. Before that happens, both fields are
+ empty.
+
+
+
+
Creating a customer
+
+ Use external_id to store your own user ID, so you can always look a
+ customer up by the identity your system already knows.
+
+
+ )
+}
diff --git a/frontend/src/pages/docs/Errors.jsx b/frontend/src/pages/docs/Errors.jsx
new file mode 100644
index 0000000..20e052b
--- /dev/null
+++ b/frontend/src/pages/docs/Errors.jsx
@@ -0,0 +1,58 @@
+import DocsPage from '../../components/DocsPage'
+import CodeBlock from '../../components/CodeBlock'
+
+const errorShape = `{
+ "error": {
+ "code": "not_found",
+ "message": "Subscription not found"
+ }
+}`
+
+const errors = [
+ ['unauthorized', '401', 'Missing bearer token'],
+ ['invalid_api_key', '401', "Token doesn't match any merchant"],
+ ['missing_idempotency_key', '400', 'POST/PATCH/DELETE missing the header'],
+ ['idempotency_key_reuse', '409', 'Same key, different request body'],
+ ['not_found', '404', 'Plan, customer, subscription, invoice, or event not found'],
+ ['plan_archived', '400', "Can't subscribe or switch to an archived plan"],
+ ['already_archived', '400', 'Plan is already archived'],
+ ['invalid_status', '400', "Can't change plan on a subscription in this state"],
+ ['no_change', '400', 'Subscription is already on that plan'],
+]
+
+export default function Errors() {
+ return (
+ {errorShape}}
+ >
+
+ code is machine-readable and safe to switch on. message is for
+ logs and support tickets. Some errors also include a param naming the field
+ they relate to.
+
+
+
+
+
+
Code
+
HTTP
+
Meaning
+
+
+
+ {errors.map(([code, http, meaning]) => (
+
+
{code}
+
{http}
+
{meaning}
+
+ ))}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/docs/EventsWebhooks.jsx b/frontend/src/pages/docs/EventsWebhooks.jsx
new file mode 100644
index 0000000..a7c1892
--- /dev/null
+++ b/frontend/src/pages/docs/EventsWebhooks.jsx
@@ -0,0 +1,83 @@
+import DocsPage from '../../components/DocsPage'
+import CodeBlock from '../../components/CodeBlock'
+
+const verifyPython = `import hmac, hashlib
+
+def verify(payload: bytes, sig: str, secret: str) -> bool:
+ expected = hmac.new(
+ secret.encode(), payload, hashlib.sha256
+ ).hexdigest()
+ return hmac.compare_digest(expected, sig)`
+
+const eventList = `invoice.created
+charge.succeeded
+charge.failed # includes failure_reason and failure_class
+charge.retrying
+charge.recovered # includes recovery_path: timing | transfer
+transfer.requested # VA number and amount included in payload
+transfer.reconciled
+subscription.past_due
+subscription.active
+subscription.paused
+subscription.cancelled
+payment.uncertain
+payment.resolved
+anomaly.detected`
+
+export default function EventsWebhooks() {
+ return (
+
+ {verifyPython}
+ {eventList}
+ >
+ }
+ >
+
+ Every important state change fires a webhook to the URL you configured, signed with your
+ webhook secret so you can confirm it actually came from Somba.
+
+
+
+
What you receive
+
+ Once a charge succeeds, Somba posts a charge.succeeded event to your
+ webhook URL. That’s the moment to unlock access in your product.
+
+ Compute an HMAC-SHA256 of the raw request body using your webhook secret, and compare it
+ against the signature Somba sends — using a constant-time comparison, never a plain{' '}
+ ==.
+
+
+
Retries and dead letters
+
+ If your endpoint doesn’t return a 2xx, Somba retries the delivery on a backoff schedule.
+ After the retry schedule is exhausted, the delivery is marked dead-lettered rather than
+ retried forever.
+
+
+
Replaying an event
+
+ If you missed a delivery — an endpoint was down, a deploy was mid-flight — you can ask
+ Somba to replay any past event by ID rather than trying to reconstruct state yourself.
+
+
+ )
+}
diff --git a/frontend/src/pages/docs/FirstRequest.jsx b/frontend/src/pages/docs/FirstRequest.jsx
new file mode 100644
index 0000000..ea5987a
--- /dev/null
+++ b/frontend/src/pages/docs/FirstRequest.jsx
@@ -0,0 +1,40 @@
+import DocsPage from '../../components/DocsPage'
+import CodeBlock from '../../components/CodeBlock'
+
+export default function FirstRequest() {
+ return (
+
+
+
+ A plan defines the amount and cadence. Create one for a gym membership billed monthly
+ at ₦15,000.00 — amounts are always in kobo, so 1500000 kobo is ₦15,000.00.
+
+ Every mutating request — POST, PATCH, or{' '}
+ DELETE — requires an Idempotency-Key header. If your
+ client retries a request because of a timeout or a dropped connection, Somba recognizes
+ the key and returns the original response instead of creating a second subscription,
+ invoice, or charge attempt.
+
+
+
In plain English: doing the same action twice should have the same effect as doing it once.
+
+
+ Somba stores the request fingerprint tied to your merchant, so retries stay safe even
+ across process restarts on your side.
+
+
+
Constructing a good key
+
+ A key should be unique per logical action, not per HTTP attempt. A pattern like{' '}
+ sub-{'{customer}'}-{'{date}'} works well — it’s stable across retries of
+ the same intent, but distinct from a genuinely new one you make later.
+
+
+ )
+}
diff --git a/frontend/src/pages/docs/Introduction.jsx b/frontend/src/pages/docs/Introduction.jsx
new file mode 100644
index 0000000..0c8d59c
--- /dev/null
+++ b/frontend/src/pages/docs/Introduction.jsx
@@ -0,0 +1,40 @@
+import { Link } from 'react-router-dom'
+import DocsPage from '../../components/DocsPage'
+
+export default function Introduction() {
+ return (
+
+
+ Somba sits between your product and Nomba’s payment rails. You tell it what plan a
+ customer is on and when the next bill should happen. Somba tracks the subscription
+ lifecycle, creates invoices, schedules charges, retries or reroutes failed payments, and
+ records what happened so it can be audited later.
+
+
+
+ To use Somba you need a Nomba merchant account and a Somba API key. Every request is
+ scoped to your merchant — you will never see another merchant’s customers, plans,
+ or invoices, and they will never see yours.
+
+
+
+ In return you get a plans and subscriptions API, an invoice record for every billing
+ period, webhooks for every state change, and a recovery engine that handles failed
+ payments without you writing retry logic. Start with{' '}
+
+ authentication
+
+ , then walk through{' '}
+
+ your first request
+
+ .
+
+ Every billing period produces exactly one invoice for a subscription. Somba enforces
+ this with a uniqueness constraint on the subscription and period together, so a retried
+ billing run can never double-invoice the same period.
+
+
+
Status flow
+
+ An invoice moves from draft to open once it’s finalized and ready to
+ be charged, then to paid once a charge settles against it — or to{' '}
+ uncollectible if recovery is exhausted without success.
+
+
+
Line items
+
+ Regular recurring invoices don’t need a breakdown — the amount is the plan price. A
+ proration invoice does: it carries line items showing the credit from the old plan and
+ the charge for the new one, so the net amount is explainable rather than a single
+ opaque number.
+
+ )
+}
+
+const transitions = [
+ ['trialing', 'first successful charge', 'active', 'The trial converted into a paying subscription.'],
+ ['trialing', 'trial ends with no payment', 'expired', 'The trial finished and nothing renewed.'],
+ ['active', 'charge fails with recoverable reason', 'past_due', 'Somba gets a chance to recover the payment.'],
+ ['active', 'charge times out', 'payment_uncertain', 'The system cannot guess, so it freezes.'],
+ ['active', 'pause request', 'paused', 'The merchant or customer asked for a temporary stop.'],
+ ['active', 'cancel request', 'cancelled', 'The subscription was deliberately ended.'],
+ ['past_due', 'retry succeeds', 'active', 'The subscription has been healed.'],
+ ['past_due', 'transfer arrives and matches open invoice', 'active', 'The customer recovered by pushing money in.'],
+ ['payment_uncertain', 'verify confirms success', 'active', 'The missing result was actually successful.'],
+ ['payment_uncertain', 'verify confirms failure', 'past_due', 'The system now knows it needs recovery.'],
+ ['paused', 'resume request', 'active', 'Billing starts again.'],
+ ['cancelled', 'recreate new plan', 'trialing or active', 'A new subscription must be created deliberately.'],
+ ['expired', 'recreate new plan', 'trialing or active', 'A new subscription starts fresh.'],
+]
+
+const gymSweep = `# periodic sweep, simplified
+for sub in subscriptions.where(status="payment_uncertain"):
+ result = nomba.verify(sub.last_order_reference)
+ if result.succeeded:
+ sub.heal_to("active")
+ elif result.failed:
+ sub.transition_to("past_due")`
+
+export default function Lifecycle() {
+ return (
+ {gymSweep}}
+ >
+
The map
+
+
Happy path
+
+
+
+
Recovery
+
+
+
+
+
+
+
Deliberate stops
+
+
+
+
+
+
+ Any transition not listed here is rejected outright. That’s deliberate — it prevents
+ accidental state changes that could create double billing or phantom access.
+
+
+
Every legal transition
+
+
+
+
Current
+
Event
+
Next
+
Why
+
+
+
+ {transitions.map((row, i) => (
+
+
{row[0]}
+
{row[1]}
+
{row[2]}
+
{row[3]}
+
+ ))}
+
+
+
+
Three transitions worth understanding deeply
+
+
How a past_due subscription heals
+
+ Recovery is not just retries. A past_due subscription heals to active{' '}
+ either because a scheduled retry succeeded, or because a transfer arrived that matched
+ the open invoice. Both are treated as a genuine recovery, not a special case.
+
+
+
What payment_uncertain means
+
+ It exists for one reason: a timeout is not the same thing as a failure. If Nomba hasn’t
+ confirmed the outcome yet, Somba doesn’t know whether money moved. Rather than risk a
+ double charge, the subscription freezes here until a verification pass settles the truth.
+ It never auto-retries in this state, because retrying blind is exactly the mistake it
+ exists to prevent.
+
+
+
How a pushed transfer restores an active subscription
+
+ When a customer pushes money to their dedicated virtual account, Somba matches the
+ transfer against an open invoice by amount and reference. A good match heals the
+ subscription backward to active — the customer never has to contact support to
+ prove they paid.
+
+ A plan defines what a customer pays and how often. Subscriptions point to a plan; the
+ plan is where the amount and cadence actually live.
+
+
+
interval and interval_count
+
+ Cadence is two fields, not a string to parse. “Every 2 months” is{' '}
+ interval: month with interval_count: 2. “Every year” is{' '}
+ interval: year with interval_count: 1.
+
+
+
Active vs. archived
+
+ Archiving a plan does not touch existing subscriptions — they keep billing exactly
+ as before. It only blocks new subscriptions from being created against it. This is how
+ you retire a pricing tier without disrupting customers already on it.
+
+ A subscription is what you create when a customer commits to a plan. Somba tracks it
+ through seven possible states, only allows the transitions that make sense, and fires a
+ webhook every time the state changes.
+
+
+
A gym membership, in plain English
+
+ A customer signs up for a monthly gym plan and starts on trialing.
+ Their first payment succeeds, and the membership becomes active. A
+ month later, a renewal fails because the account is empty — the membership moves to{' '}
+ past_due while Somba works on recovering it. Somba retries at a
+ better time and it heals back to active. Later, a renewal times
+ out with no clear result, so the membership freezes at{' '}
+ payment_uncertain rather than guessing. A verification pass
+ confirms the payment actually went through, and it heals back to{' '}
+ active again.
+
+
+
+ The full state list and every legal transition between them are on{' '}
+
+ the subscription lifecycle
+ {' '}
+ page.
+
+
+
Grace period
+
+ A subscription in past_due is not immediately cut off. Somba gives it a grace
+ window while recovery is attempted, so a customer who is genuinely going to pay doesn’t
+ lose access over a bad morning.
+
+
+
Heal-backward
+
+ Heal-backward means a subscription can move from a worse state back to a healthy one
+ without you doing anything. If a payment that looked failed or uncertain turns out to
+ have succeeded, the subscription heals back to active on its own — your
+ customer never needs to re-subscribe.
+
+
+
+
Subscribing a customer
+
+ Subscribing starts the billing relationship. Somba schedules the first charge and every
+ renewal after it — you don’t need a cron job or a scheduler of your own.
+
+ When a charge fails, Somba classifies the reason and picks the next step itself: retry
+ at a better time, switch to transfer fallback, or stop entirely if the payment looks
+ unsafe. Your job is to react to the webhooks, not to reimplement this logic.
+
+
+
Timing recovery
+
+ The account was probably just empty. Somba schedules a retry for a more likely funding
+ window and sends charge.retrying. If it later succeeds, you get{' '}
+ charge.recovered with recovery_path: "timing" — update the
+ subscription status in your UI and move on.
+
+
+
Transfer fallback
+
+ The card is dead or pulling no longer makes sense. Somba sends{' '}
+ transfer.requested with a dedicated virtual account number — show that to the
+ customer. Once the transfer is reconciled, you get charge.recovered with{' '}
+ recovery_path: "transfer".
+
+
+
Fraud block
+
+ The payment looked unsafe. Somba does not retry. You’ll see the subscription move to{' '}
+ past_due without a scheduled recovery — treat this as a case for manual
+ review, not an automatic retry candidate.
+
+
+
+
Asking for an immediate retry
+
+ If a customer tells you they’ve topped up, you can ask Somba to retry right away
+ instead of waiting for the scheduled window.
+
+ Change a customer’s plan with a single PATCH call. Somba works out how much
+ value is left on the old plan, how much the new plan costs for the remaining days, and
+ charges only the difference.
+
+
+
+ {patchCall}
+ {patchResponse}
+
+
+
+ In the example, the customer had unused time on Basic worth ₦4,200.00. The remaining
+ days on Pro cost ₦9,800.00. Somba charges the net ₦5,600.00 immediately, and returns the
+ proration invoice with both line items so the amount is never a mystery to you or the
+ customer.
+
+
+
Downgrades work in reverse
+
+ Downgrading stores the unused value as credit_balance on the customer instead
+ of refunding it. The next renewal checks that balance before charging — if it fully
+ covers the renewal, Somba doesn’t call Nomba for that cycle at all.
+
+
+ )
+}
diff --git a/frontend/src/pages/docs/guides/Recovery.jsx b/frontend/src/pages/docs/guides/Recovery.jsx
new file mode 100644
index 0000000..cd2a27c
--- /dev/null
+++ b/frontend/src/pages/docs/guides/Recovery.jsx
@@ -0,0 +1,60 @@
+import DocsPage from '../../../components/DocsPage'
+import CodeBlock from '../../../components/CodeBlock'
+
+const why = `# Why not just retry on a second rail?
+# If the account was empty on rail A, it's usually
+# still empty on rail B — same customer, same balance.
+# Somba prefers: wait for a better window, or ask for
+# a transfer, over blind rerouting between pull rails.`
+
+export default function Recovery() {
+ return (
+ {why}}
+ >
+
+ Most payment tools stop at “charge failed.” Somba treats that as the start of a
+ second decision: is this worth retrying, and if so, when and how?
+
+
+
Timing-based recovery
+
+ If a customer’s account was empty at 8 a.m., that’s not proof they’ll still be empty by
+ evening. Somba uses signals like expected payday and recent incoming transfers to retry
+ at a moment when the charge is actually likely to succeed, instead of hammering the same
+ card on a fixed interval.
+
+
+
Transfer fallback
+
+ When pulling stops making sense — a dead card, a pattern of hard declines — Somba asks
+ the customer to push money to a dedicated virtual account instead. Transfers are
+ familiar and visible in Nigeria, and easy to reconcile automatically once they land.
+
+
+
Why not just try a second pull rail
+
+ It sounds like an obvious next step, but it usually just reaches the same empty account
+ through a different door — more noise, more failed attempts, no better outcome. Somba’s
+ position is that timing plus transfer fallback solves the real problem more honestly than
+ rerouting between rails does.
+
+
+ )
+}
diff --git a/frontend/src/pages/docs/guides/RecurringBilling.jsx b/frontend/src/pages/docs/guides/RecurringBilling.jsx
new file mode 100644
index 0000000..ff1a479
--- /dev/null
+++ b/frontend/src/pages/docs/guides/RecurringBilling.jsx
@@ -0,0 +1,66 @@
+import DocsPage from '../../../components/DocsPage'
+import CodeBlock from '../../../components/CodeBlock'
+
+const steps = `# 1. Create the plan
+POST /v1/plans { name, amount, currency, interval, interval_count }
+
+# 2. Create the customer
+POST /v1/customers { external_id, email, name }
+
+# 3. Subscribe them
+POST /v1/subscriptions { customer_id, plan_id }
+
+# 4. Somba bills automatically on the cycle you set
+# 5. You receive charge.succeeded on the first payment`
+
+export default function RecurringBilling() {
+ return (
+ {steps}}
+ >
+
+ Start by deciding your pricing shape and creating a plan for it. A plan is just an
+ amount and a cadence — you can create as many as you have pricing tiers.
+
+
+
+ Next, create a customer record the moment someone signs up in your product. Set{' '}
+ external_id to the user ID you already have, so this record is always
+ reachable from your own system without a second lookup table.
+
+
+
+ Subscribe the customer to the plan. This is the point where billing actually starts —
+ Somba calculates the first current_period_start and{' '}
+ current_period_end, and schedules the first charge.
+
+ From here you do nothing. Somba’s scheduler finds subscriptions due for billing, attempts
+ the charge, creates the invoice, and fires charge.succeeded or{' '}
+ charge.failed. Listen for the success event to grant access, and you have a
+ working recurring billing flow.
+
+ Recompute the HMAC-SHA256 of the raw request body using your webhook secret, and compare
+ it against the X-Somba-Signature header with a constant-time comparison.
+ Never process a payload whose signature doesn’t match.
+
+
+
+ {python}
+
+
+ )
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..c4069b7
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+})
diff --git a/pxxl.toml b/pxxl.toml
deleted file mode 100644
index b3227f7..0000000
--- a/pxxl.toml
+++ /dev/null
@@ -1,6 +0,0 @@
-language = "python"
-framework = "fastapi"
-packageManager = "pip"
-installCommand = "pip install -r requirements.txt"
-startCommand = "uvicorn main:app --host 0.0.0.0 --port ${PORT:-8000}"
-port = 8000
diff --git a/somba/api/app.py b/somba/api/app.py
index 8f1576c..33ae430 100644
--- a/somba/api/app.py
+++ b/somba/api/app.py
@@ -4,11 +4,12 @@
from fastapi import Depends, FastAPI, Request
from fastapi.exceptions import RequestValidationError
+from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
-from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
+from somba.api.auth import router as auth_router
from somba.api.customers import router as customers_router
from somba.api.errors import APIError, error_response
from somba.api.events import router as events_router
@@ -21,10 +22,16 @@
from somba.api.webhooks import router as webhooks_router
from somba.db.models import Merchant
from somba.db.session import get_db, init_db
-from somba.security import generate_api_key_material
app = FastAPI(title="Somba")
app.add_middleware(IdempotencyMiddleware)
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+app.include_router(auth_router)
app.include_router(webhooks_router)
app.include_router(plans_router)
app.include_router(customers_router)
@@ -58,38 +65,6 @@ async def validation_error_handler(_: Request, exc: RequestValidationError) -> J
)
-class MerchantCreateRequest(BaseModel):
- name: str
- webhook_url: str | None = None
- webhook_secret: str = ""
-
-
-@app.post("/v1/merchants", status_code=201)
-def create_merchant(
- body: MerchantCreateRequest,
- db: Session = Depends(get_db),
-) -> dict[str, object]:
- key = generate_api_key_material()
- merchant = Merchant(
- name=body.name,
- api_key_id=key.public_id,
- api_key_hash=key.secret_hash,
- webhook_url=body.webhook_url,
- webhook_secret=body.webhook_secret,
- )
- db.add(merchant)
- db.commit()
- db.refresh(merchant)
- return {
- "merchant": {
- "id": merchant.id,
- "name": merchant.name,
- "webhook_url": merchant.webhook_url,
- },
- "api_key": key.token,
- }
-
-
@app.get("/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@@ -101,7 +76,6 @@ def me(current_merchant: Merchant = Depends(get_current_merchant)) -> dict[str,
"merchant": {
"id": current_merchant.id,
"name": current_merchant.name,
- "api_key_id": current_merchant.api_key_id,
"webhook_url": current_merchant.webhook_url,
}
}
diff --git a/somba/api/auth.py b/somba/api/auth.py
new file mode 100644
index 0000000..0df29c3
--- /dev/null
+++ b/somba/api/auth.py
@@ -0,0 +1,199 @@
+"""Dashboard authentication: email/password signup and login, session-scoped
+merchant lookup, and minting the named API keys merchants use in their own
+code.
+
+This is deliberately a separate credential from API keys. Email/password
+gets a merchant into the dashboard; API keys are things they mint from
+inside it and use in their own backend. Revoking one key, or ending a
+session, never touches the others.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+
+from fastapi import APIRouter, Depends, Request
+from pydantic import BaseModel, EmailStr, Field
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from somba.api.errors import APIError
+from somba.db.models import ApiKey, Merchant, MerchantSession
+from somba.db.session import get_db
+from somba.security import (
+ generate_api_key_material,
+ generate_session_token,
+ hash_password,
+ parse_session_token,
+ verify_api_key_secret,
+ verify_password,
+)
+
+router = APIRouter(prefix="/v1/auth", tags=["auth"])
+
+
+def _merchant_to_dict(merchant: Merchant) -> dict:
+ return {"id": merchant.id, "name": merchant.name, "email": merchant.email}
+
+
+def _api_key_to_dict(key: ApiKey) -> dict:
+ return {
+ "id": key.id,
+ "name": key.name,
+ "key_id": key.key_id,
+ "created_at": key.created_at.isoformat() if key.created_at else None,
+ "last_used_at": key.last_used_at.isoformat() if key.last_used_at else None,
+ }
+
+
+def _issue_session(db: Session, merchant: Merchant) -> str:
+ token = generate_session_token()
+ db.add(
+ MerchantSession(
+ merchant_id=merchant.id,
+ session_id=token.session_id,
+ session_secret_hash=token.secret_hash,
+ )
+ )
+ db.commit()
+ return token.token
+
+
+def get_current_dashboard_merchant(
+ request: Request,
+ db: Session = Depends(get_db),
+) -> Merchant:
+ """Resolve the current merchant from a dashboard session bearer token."""
+
+ header = request.headers.get("Authorization", "")
+ if not header.startswith("Bearer "):
+ raise APIError(code="unauthorized", message="Missing session token", status_code=401)
+ token = header.removeprefix("Bearer ").strip()
+
+ try:
+ session_id, secret = parse_session_token(token)
+ except ValueError as exc:
+ raise APIError(code="invalid_session", message=str(exc), status_code=401) from exc
+
+ session = db.scalar(
+ select(MerchantSession).where(MerchantSession.session_id == session_id)
+ )
+ if session is None or not verify_api_key_secret(secret, session.session_secret_hash):
+ raise APIError(code="invalid_session", message="Invalid or expired session", status_code=401)
+
+ merchant = db.get(Merchant, session.merchant_id)
+ if merchant is None:
+ raise APIError(code="invalid_session", message="Invalid or expired session", status_code=401)
+ return merchant
+
+
+def _get_api_key_or_404(db: Session, key_row_id: int, merchant: Merchant) -> ApiKey:
+ key = db.scalar(
+ select(ApiKey).where(
+ ApiKey.id == key_row_id,
+ ApiKey.merchant_id == merchant.id,
+ ApiKey.revoked_at.is_(None),
+ )
+ )
+ if key is None:
+ raise APIError(code="not_found", message="API key not found", status_code=404)
+ return key
+
+
+class SignupRequest(BaseModel):
+ name: str = Field(max_length=255)
+ email: EmailStr
+ password: str = Field(min_length=8, max_length=255)
+
+
+class LoginRequest(BaseModel):
+ email: EmailStr
+ password: str
+
+
+class ApiKeyCreateRequest(BaseModel):
+ name: str = Field(max_length=255)
+
+
+@router.post("/signup", status_code=201)
+def signup(body: SignupRequest, db: Session = Depends(get_db)) -> dict:
+ existing = db.scalar(select(Merchant).where(Merchant.email == body.email))
+ if existing is not None:
+ raise APIError(code="email_taken", message="An account with this email already exists", status_code=409)
+
+ merchant = Merchant(
+ name=body.name,
+ email=body.email,
+ password_hash=hash_password(body.password),
+ )
+ db.add(merchant)
+ db.commit()
+ db.refresh(merchant)
+
+ session_token = _issue_session(db, merchant)
+ return {"merchant": _merchant_to_dict(merchant), "session_token": session_token}
+
+
+@router.post("/login")
+def login(body: LoginRequest, db: Session = Depends(get_db)) -> dict:
+ merchant = db.scalar(select(Merchant).where(Merchant.email == body.email))
+ if merchant is None or merchant.password_hash is None or not verify_password(
+ body.password, merchant.password_hash
+ ):
+ raise APIError(code="invalid_credentials", message="Invalid email or password", status_code=401)
+
+ session_token = _issue_session(db, merchant)
+ return {"merchant": _merchant_to_dict(merchant), "session_token": session_token}
+
+
+@router.get("/me")
+def me(merchant: Merchant = Depends(get_current_dashboard_merchant)) -> dict:
+ return {"merchant": _merchant_to_dict(merchant)}
+
+
+@router.get("/api-keys")
+def list_api_keys(
+ db: Session = Depends(get_db),
+ merchant: Merchant = Depends(get_current_dashboard_merchant),
+) -> dict:
+ keys = list(
+ db.scalars(
+ select(ApiKey)
+ .where(ApiKey.merchant_id == merchant.id, ApiKey.revoked_at.is_(None))
+ .order_by(ApiKey.created_at.desc())
+ )
+ )
+ return {"api_keys": [_api_key_to_dict(k) for k in keys]}
+
+
+@router.post("/api-keys", status_code=201)
+def create_api_key(
+ body: ApiKeyCreateRequest,
+ db: Session = Depends(get_db),
+ merchant: Merchant = Depends(get_current_dashboard_merchant),
+) -> dict:
+ """Mint a new named API key for the merchant. Existing keys are untouched."""
+
+ material = generate_api_key_material()
+ key = ApiKey(
+ merchant_id=merchant.id,
+ name=body.name,
+ key_id=material.public_id,
+ key_hash=material.secret_hash,
+ )
+ db.add(key)
+ db.commit()
+ db.refresh(key)
+ return {**_api_key_to_dict(key), "api_key": material.token}
+
+
+@router.delete("/api-keys/{key_row_id}")
+def revoke_api_key(
+ key_row_id: int,
+ db: Session = Depends(get_db),
+ merchant: Merchant = Depends(get_current_dashboard_merchant),
+) -> dict:
+ key = _get_api_key_or_404(db, key_row_id, merchant)
+ key.revoked_at = datetime.now(timezone.utc)
+ db.commit()
+ return {"id": key.id, "revoked": True}
diff --git a/somba/api/middleware/auth.py b/somba/api/middleware/auth.py
index 95dea84..41cd982 100644
--- a/somba/api/middleware/auth.py
+++ b/somba/api/middleware/auth.py
@@ -2,12 +2,14 @@
from __future__ import annotations
+from datetime import datetime, timezone
+
from fastapi import Depends, Request
from sqlalchemy import select
from sqlalchemy.orm import Session
from somba.api.errors import APIError
-from somba.db.models import Merchant
+from somba.db.models import ApiKey, Merchant
from somba.db.session import get_db
from somba.security import parse_api_key, verify_api_key_secret
@@ -42,11 +44,24 @@ def get_current_merchant(
status_code=401,
) from exc
- merchant = db.scalar(select(Merchant).where(Merchant.api_key_id == public_id))
- if merchant is None or not verify_api_key_secret(secret, merchant.api_key_hash):
+ api_key = db.scalar(
+ select(ApiKey).where(ApiKey.key_id == public_id, ApiKey.revoked_at.is_(None))
+ )
+ if api_key is None or not verify_api_key_secret(secret, api_key.key_hash):
+ raise APIError(
+ code="invalid_api_key",
+ message="Invalid API key",
+ status_code=401,
+ )
+
+ merchant = db.get(Merchant, api_key.merchant_id)
+ if merchant is None:
raise APIError(
code="invalid_api_key",
message="Invalid API key",
status_code=401,
)
+
+ api_key.last_used_at = datetime.now(timezone.utc)
+ db.commit()
return merchant
diff --git a/somba/api/middleware/idempotency.py b/somba/api/middleware/idempotency.py
index 2c54a78..4f0bdd5 100644
--- a/somba/api/middleware/idempotency.py
+++ b/somba/api/middleware/idempotency.py
@@ -24,7 +24,7 @@
from starlette.responses import Response
from somba.api.errors import APIError, error_response
-from somba.db.models import IdempotencyRecord, IdempotencyRecordStatus, Merchant
+from somba.db.models import ApiKey, IdempotencyRecord, IdempotencyRecordStatus
from somba.db.session import get_db
from somba.security import parse_api_key, verify_api_key_secret
@@ -32,13 +32,17 @@
MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
IDEMPOTENCY_EXEMPT = {"/v1/webhooks/nomba"}
+IDEMPOTENCY_EXEMPT_PREFIXES = ("/v1/auth/",)
class IdempotencyMiddleware(BaseHTTPMiddleware):
"""Require an idempotency key and replay stored responses on repeat keys."""
async def dispatch(self, request: Request, call_next):
- if request.method not in MUTATING_METHODS or request.url.path in IDEMPOTENCY_EXEMPT:
+ exempt = request.url.path in IDEMPOTENCY_EXEMPT or request.url.path.startswith(
+ IDEMPOTENCY_EXEMPT_PREFIXES
+ )
+ if request.method not in MUTATING_METHODS or exempt:
return await call_next(request)
key = request.headers.get("Idempotency-Key", "").strip()
@@ -179,10 +183,12 @@ def _resolve_merchant_id(self, request: Request) -> int | None:
return None
db, gen = self._session(request)
try:
- merchant = db.scalar(select(Merchant).where(Merchant.api_key_id == public_id))
- if merchant is None or not verify_api_key_secret(secret, merchant.api_key_hash):
+ api_key = db.scalar(
+ select(ApiKey).where(ApiKey.key_id == public_id, ApiKey.revoked_at.is_(None))
+ )
+ if api_key is None or not verify_api_key_secret(secret, api_key.key_hash):
return None
- return merchant.id
+ return api_key.merchant_id
finally:
self._close(gen)
diff --git a/somba/db/migrations/versions/0007_merchant_dashboard_auth.py b/somba/db/migrations/versions/0007_merchant_dashboard_auth.py
new file mode 100644
index 0000000..f6aac3d
--- /dev/null
+++ b/somba/db/migrations/versions/0007_merchant_dashboard_auth.py
@@ -0,0 +1,58 @@
+"""Add dashboard email/password auth, separate from the API key credential.
+
+Merchants now sign up with name/email/password and mint an API key later from
+the dashboard, instead of getting one immediately at account creation. Adds
+merchants.email/password_hash, makes api_key_id/api_key_hash nullable (no key
+until minted), and adds merchant_sessions for dashboard login sessions.
+
+Revision ID: 0007
+Revises: 0006
+Create Date: 2026-07-03
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = "0007"
+down_revision = "0006"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ with op.batch_alter_table("merchants") as batch_op:
+ batch_op.add_column(sa.Column("email", sa.String(255), nullable=True))
+ batch_op.add_column(sa.Column("password_hash", sa.String(255), nullable=True))
+ batch_op.alter_column("api_key_id", existing_type=sa.String(32), nullable=True)
+ batch_op.alter_column("api_key_hash", existing_type=sa.String(255), nullable=True)
+ batch_op.alter_column(
+ "webhook_secret", existing_type=sa.String(255), nullable=False, server_default=""
+ )
+ op.create_index("ix_merchants_email", "merchants", ["email"], unique=True)
+
+ op.create_table(
+ "merchant_sessions",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id"), nullable=False),
+ sa.Column("session_id", sa.String(32), unique=True, nullable=False),
+ sa.Column("session_secret_hash", sa.String(255), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ )
+ op.create_index("ix_merchant_sessions_merchant_id", "merchant_sessions", ["merchant_id"])
+ op.create_index("ix_merchant_sessions_session_id", "merchant_sessions", ["session_id"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_merchant_sessions_session_id", table_name="merchant_sessions")
+ op.drop_index("ix_merchant_sessions_merchant_id", table_name="merchant_sessions")
+ op.drop_table("merchant_sessions")
+
+ op.drop_index("ix_merchants_email", table_name="merchants")
+ with op.batch_alter_table("merchants") as batch_op:
+ batch_op.alter_column("webhook_secret", existing_type=sa.String(255), nullable=False)
+ batch_op.alter_column("api_key_hash", existing_type=sa.String(255), nullable=False)
+ batch_op.alter_column("api_key_id", existing_type=sa.String(32), nullable=False)
+ batch_op.drop_column("password_hash")
+ batch_op.drop_column("email")
diff --git a/somba/db/migrations/versions/0008_named_api_keys.py b/somba/db/migrations/versions/0008_named_api_keys.py
new file mode 100644
index 0000000..1809975
--- /dev/null
+++ b/somba/db/migrations/versions/0008_named_api_keys.py
@@ -0,0 +1,84 @@
+"""Move API keys off the merchant row into a proper api_keys table.
+
+Merchants can now mint several named API keys from the dashboard (e.g. one
+per environment) instead of holding a single unnamed key directly on the
+merchant record. Existing single keys are carried over as a key named
+"Default" before the old columns are dropped.
+
+Revision ID: 0008
+Revises: 0007
+Create Date: 2026-07-03
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+
+revision = "0008"
+down_revision = "0007"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "api_keys",
+ sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
+ sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id"), nullable=False),
+ sa.Column("name", sa.String(255), nullable=False),
+ sa.Column("key_id", sa.String(32), unique=True, nullable=False),
+ sa.Column("key_hash", sa.String(255), nullable=False),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
+ sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
+ sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
+ )
+ op.create_index("ix_api_keys_merchant_id", "api_keys", ["merchant_id"])
+ op.create_index("ix_api_keys_key_id", "api_keys", ["key_id"])
+
+ conn = op.get_bind()
+ conn.execute(
+ sa.text(
+ """
+ INSERT INTO api_keys (merchant_id, name, key_id, key_hash)
+ SELECT id, 'Default', api_key_id, api_key_hash
+ FROM merchants
+ WHERE api_key_id IS NOT NULL
+ """
+ )
+ )
+
+ with op.batch_alter_table("merchants") as batch_op:
+ batch_op.drop_index("ix_merchants_api_key_id")
+ batch_op.drop_column("api_key_hash")
+ batch_op.drop_column("api_key_id")
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("merchants") as batch_op:
+ batch_op.add_column(sa.Column("api_key_id", sa.String(32), nullable=True))
+ batch_op.add_column(sa.Column("api_key_hash", sa.String(255), nullable=True))
+ batch_op.create_index("ix_merchants_api_key_id", ["api_key_id"])
+
+ conn = op.get_bind()
+ conn.execute(
+ sa.text(
+ """
+ UPDATE merchants
+ SET api_key_id = (
+ SELECT key_id FROM api_keys
+ WHERE api_keys.merchant_id = merchants.id
+ ORDER BY api_keys.created_at ASC LIMIT 1
+ ),
+ api_key_hash = (
+ SELECT key_hash FROM api_keys
+ WHERE api_keys.merchant_id = merchants.id
+ ORDER BY api_keys.created_at ASC LIMIT 1
+ )
+ """
+ )
+ )
+
+ op.drop_index("ix_api_keys_key_id", table_name="api_keys")
+ op.drop_index("ix_api_keys_merchant_id", table_name="api_keys")
+ op.drop_table("api_keys")
diff --git a/somba/db/models.py b/somba/db/models.py
index d1d8b21..b128398 100644
--- a/somba/db/models.py
+++ b/somba/db/models.py
@@ -22,13 +22,56 @@ class Merchant(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
- api_key_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False)
- api_key_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True, nullable=True)
+ password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
webhook_url: Mapped[str | None] = mapped_column(String(2048), nullable=True)
- webhook_secret: Mapped[str] = mapped_column(String(255), nullable=False)
+ webhook_secret: Mapped[str] = mapped_column(String(255), nullable=False, default="")
plans: Mapped[list["Plan"]] = relationship(back_populates="merchant")
customers: Mapped[list["Customer"]] = relationship(back_populates="merchant")
+ sessions: Mapped[list["MerchantSession"]] = relationship(back_populates="merchant")
+ api_keys: Mapped[list["ApiKey"]] = relationship(back_populates="merchant")
+
+
+class ApiKey(Base):
+ """A named API key a merchant mints from the dashboard to use in their own code.
+
+ A merchant can hold several — e.g. one per environment — each independently
+ named and revocable without touching the others.
+ """
+
+ __tablename__ = "api_keys"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ merchant_id: Mapped[int] = mapped_column(ForeignKey("merchants.id"), index=True, nullable=False)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ key_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False)
+ key_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+ last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+ merchant: Mapped["Merchant"] = relationship(back_populates="api_keys")
+
+
+class MerchantSession(Base):
+ """A dashboard login session — a separate credential from the API key.
+
+ Merchants authenticate to the dashboard with email/password to manage their
+ account and mint API keys; they authenticate to the billing API itself with
+ the API key. Keeping the two credentials apart means rotating one never
+ invalidates the other.
+ """
+
+ __tablename__ = "merchant_sessions"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+ merchant_id: Mapped[int] = mapped_column(ForeignKey("merchants.id"), index=True, nullable=False)
+ session_id: Mapped[str] = mapped_column(String(32), unique=True, index=True, nullable=False)
+ session_secret_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
+
+ merchant: Mapped["Merchant"] = relationship(back_populates="sessions")
class PlanStatus(str, Enum):
diff --git a/somba/security.py b/somba/security.py
index 671abdf..57be34a 100644
--- a/somba/security.py
+++ b/somba/security.py
@@ -8,6 +8,7 @@
import bcrypt
API_KEY_PREFIX = "sk-somba-"
+SESSION_TOKEN_PREFIX = "sess-somba-"
@dataclass(frozen=True)
@@ -63,3 +64,58 @@ def verify_api_key_secret(secret: str, secret_hash: str) -> bool:
return bcrypt.checkpw(secret.encode("utf-8"), secret_hash.encode("utf-8"))
except ValueError:
return False
+
+
+def hash_password(password: str) -> str:
+ """Hash a merchant dashboard password with bcrypt."""
+
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
+
+
+def verify_password(password: str, password_hash: str) -> bool:
+ """Check a merchant dashboard password against its stored bcrypt hash."""
+
+ try:
+ return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
+ except ValueError:
+ return False
+
+
+@dataclass(frozen=True)
+class SessionTokenMaterial:
+ """Convenience container for a generated dashboard session token."""
+
+ session_id: str
+ secret: str
+ token: str
+ secret_hash: str
+
+
+def generate_session_token() -> SessionTokenMaterial:
+ """Generate a dashboard session token (separate credential from API keys)."""
+
+ session_id = secrets.token_hex(8)
+ secret = secrets.token_urlsafe(32)
+ token = f"{SESSION_TOKEN_PREFIX}{session_id}.{secret}"
+ return SessionTokenMaterial(
+ session_id=session_id,
+ secret=secret,
+ token=token,
+ secret_hash=hash_api_key_secret(secret),
+ )
+
+
+def parse_session_token(token: str) -> tuple[str, str]:
+ """Split a session bearer token into session id and secret."""
+
+ if not token.startswith(SESSION_TOKEN_PREFIX):
+ raise ValueError("Session token must start with sess-somba-")
+
+ body = token[len(SESSION_TOKEN_PREFIX) :]
+ if "." not in body:
+ raise ValueError("Session token must include a session id and secret")
+
+ session_id, secret = body.split(".", 1)
+ if not session_id or not secret:
+ raise ValueError("Session token is missing a session id or secret")
+ return session_id, secret
diff --git a/tests/conftest.py b/tests/conftest.py
index 864125f..2ec46d0 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -9,7 +9,7 @@
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
-from somba.db.models import Base, Customer, Merchant, Plan, PlanStatus, Subscription, SubscriptionStatus
+from somba.db.models import ApiKey, Base, Customer, Merchant, Plan, PlanStatus, Subscription, SubscriptionStatus
from somba.security import generate_api_key_material
@@ -39,15 +39,19 @@ def db(db_engine) -> Session:
def make_merchant(db):
"""Factory: create and persist a merchant, return (merchant, raw_token)."""
def _make(name: str = "Test Merchant") -> tuple[Merchant, str]:
+ m = Merchant(name=name, webhook_url=None, webhook_secret="whsec_test")
+ db.add(m)
+ db.flush()
+
key = generate_api_key_material()
- m = Merchant(
- name=name,
- api_key_id=key.public_id,
- api_key_hash=key.secret_hash,
- webhook_url=None,
- webhook_secret="whsec_test",
+ db.add(
+ ApiKey(
+ merchant_id=m.id,
+ name="Default",
+ key_id=key.public_id,
+ key_hash=key.secret_hash,
+ )
)
- db.add(m)
db.commit()
db.refresh(m)
return m, key.token
diff --git a/tests/unit/test_recovery_engine.py b/tests/unit/test_recovery_engine.py
index d8eb527..a56ff74 100644
--- a/tests/unit/test_recovery_engine.py
+++ b/tests/unit/test_recovery_engine.py
@@ -131,9 +131,7 @@ def test_transfer_path_writes_no_recovery_schedule(db):
def _make_real_subscription(db, *, customer_name: str | None = "Real Customer") -> Subscription:
- merchant = Merchant(
- name="M", api_key_id="k" * 16, api_key_hash="h", webhook_secret="s",
- )
+ merchant = Merchant(name="M", webhook_secret="s")
db.add(merchant)
db.flush()
plan = Plan(merchant_id=merchant.id, name="P", amount=1000, currency="NGN", interval="month")