Internal · engineering orientation

SparkBiz Blueprint

You already know Python and databases. This is the part in between — how a browser, a running Python program, and PostgreSQL actually talk to each other, traced from a click in the browser down to a row in the database and back up again. Ten levels, surface to foundation.

00The players

The Cast

Before any code: four things exist, on different computers, and they only ever talk to each other over the network. Naming them first makes everything downstream click faster.

  • Your browser — runs the HTML/CSS/JS files. It has no idea what Python or PostgreSQL are; it only speaks HTTP.
  • The backend — a Python program (FastAPI) that never stops running, sitting on a server called Railway.
  • The database — PostgreSQL, also on Railway, sitting right next to the backend.
  • Outside services — Stripe, AWS, Resend, Google/Microsoft: separate companies' servers the backend calls out to when it needs to charge a card or send an email.
The one rule that unlocks everything else

The browser never touches the database. Not once, not ever. Every number you see on a SparkBiz page got there because the browser asked the backend nicely over the network, and the backend went and looked it up — the same way any of your Python scripts would call cursor.execute(...), just with a network hop stitched in front of it.

Fig. A — the whole system, one diagram
loads the page SQL fetch() — HTTPS + JSON HTTPS (billing / email / storage / OAuth) YOUR BROWSER HTML · CSS · JS (Alpine.js, no build step) NETLIFY just hands out files frontend/*.html, website/*.html RAILWAY FASTAPI Python backend POSTGRES the database STRIPE AWS · SES / S3 RESEND GOOGLE / MICROSOFT
the hop this whole guide is about everything else
Two servers, not one. Netlify only ever hands out plain files — no Python runs there. Railway is where your actual backend process lives, right beside its database. The browser talks to both, separately, for different reasons.
01The client

Street Level — what the browser runs

SparkBiz's frontend is not compiled or built. There's no step that turns invoicing.html into something else before it ships — the file you'd open in a text editor is exactly what Netlify hands to the browser. No React, no bundler, no npm run build.

Interactivity comes from Alpine.js, a small library that lets you write things like x-data, x-show, and @click straight into HTML attributes. Think of it as sprinkling behaviour onto plain HTML, rather than rewriting the page as a JavaScript application.

The one habit every page shares

On load, every page calls the backend — fetch('/settings') is a common one — to ask "who's asking, and what do they have access to?", then paints itself from the answer. invoicing.html is the literal same file for every single business using SparkBiz; nothing is baked in per-customer. The page is a template; the API call is what makes it theirs.

Bridge

If you've ever templated an HTML page with Jinja2 or built a page server-side in Django, this is the same idea flipped inside out: instead of the server filling in the blanks before sending HTML, the browser gets a blank template and fills in the blanks itself, after asking the API for the data.

02The concept you asked about

The Wire — what an API actually is

You already know what a Python function is: it takes inputs, does work, returns an output.

def get_invoice(invoice_id):
    return db.query(Invoice).filter(Invoice.id == invoice_id).first()

An API endpoint is the same idea — except the thing calling it isn't another line of Python in the same process. It's a completely different program, running on a completely different computer (your customer's browser), reaching across the internet to ask for it. Two separate computers can't just call each other's functions directly, so they need an agreed-upon way to phrase the request and the answer. That's HTTP for the phrasing, and JSON for the data — which, if you've ever looked at a Python dict, you already know: {"invoice_number": "INV-0004", "total": "220.00"} is dict-shaped text, sent down a wire.

So this real function, sitting in routers/email_send.py:

@router.post("/send/{invoice_id}")
def send_invoice(invoice_id: str, user=Depends(_auth), db=Depends(get_db)):
    ...
    return {"message": f"Invoice {inv.invoice_number} sent to {inv.client.email}"}

is, from the browser's point of view, "a thing I can trigger by sending a POST request to /email/send/abc123, with my login token attached, and I'll get {"message": "..."} back as text." That's the whole trick. An API is just: your Python functions, made reachable from outside the process, over HTTP.

Four things worth actually knowing about HTTP

The verb

GET reads, no side effects. POST creates or triggers something. PATCH changes part of a record. DELETE removes one. SparkBiz's routers use exactly these four — they map almost 1:1 onto plain SELECT / INSERT / UPDATE / DELETE.

The URL

/invoices/abc123 reads like a sentence: "the invoice with id abc123." The URL names what; the verb says what to do to it.

The headers

Metadata riding alongside the request. SparkBiz's most important one: Authorization: Bearer eyJhbGc... — the ID card, attached to nearly every single request the frontend makes.

The status code

200 fine · 400 you sent something invalid · 401/403 not allowed · 404 doesn't exist · 500 the backend itself broke. You'll see these constantly once you know to look — Chrome's Network tab shows every one, live.

03A real request, start to finish

Follow One Click

Theory only goes so far. Here's an actual SparkBiz feature, traced exactly as the code runs it: a tenant clicks "Send" on a draft invoice in invoicing.html.

Fig. B — sending one invoice, hop by hop
BROWSER invoicing.html BACKEND email_send.py DATABASE PostgreSQL EMAIL PROVIDER SES / Resend ① Click "Send" → sendOne(id) runs ② POST /email/send/{id} Authorization: Bearer token ③ SELECT user WHERE id = ... the _auth dependency user row ④ SELECT invoice, client, tenant invoice + tenant rows ⑤ generate_invoice_pdf() plain Python (ReportLab) — no network, no DB ⑥ send the email, PDF attached accepted ⑦ UPDATE invoice SET status='sent' ⑧ 200 OK { "message": "Invoice ... sent" } ⑨ toast shown, row now reads "Sent"
solid = a request, going out dashed = a response, coming back the only two hops that cross the network
Steps ③, ④, ⑤ and ⑦ are exactly the Python and SQL you already write every day — no different in kind from a script you'd run at a terminal. The two teal arrows are the genuinely new part: one HTTP request going out from the browser, one HTTP response coming back. Everything in between is just routers/email_send.py running top to bottom, same as any other function call.

The same trip, in prose

  1. 01
    Click
    Alpine's @click runs sendOne(id, number) in invoicing.html.
  2. 02
    Browser → Backend
    fetch(`${API}/email/send/${id}`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } }) — the token is whatever login saved into localStorage.
  3. 03
    Who is this?
    FastAPI runs _auth before your function even starts. It unpacks the token (pure math — see Level 06), then runs one real query: SELECT ... FROM spkbz_users WHERE id = ....
  4. 04
    Load the invoice
    send_invoice() queries the Invoice and Tenant rows — ordinary SQLAlchemy, ordinary SELECTs.
  5. 05
    Build the PDF
    generate_invoice_pdf() in services/pdf_generator.py — plain Python (ReportLab), no network call, no DB call. It's just a function that returns bytes.
  6. 06
    Backend → Email provider
    _send_invoice() hands off to one shared helper — send_via_default_channel() in services/email_channel.py — which makes the outbound HTTP call, tries AWS SES first and falls back to Resend automatically if SES errors, carries the PDF as an attachment, and records which provider won in the EmailSendLog table. Every module's transactional email (Variations, E-Sign, E-Request) goes through this same function. Before it goes, the email body gets the business's signature from email_signature_html() in services/email_signature.py (Best Regards, contact details, logo, footer note, footer logos), and services/inline_images.py embeds those logo images inside the message itself (cid: images) rather than linking to them — so Gmail and Outlook show them straight away. That embedding happens inside the SES/SMTP senders and around every Gmail/Microsoft send call, so no module has to think about it.
  7. 07
    Mark it sent
    inv.status = SENT; db.commit() — one UPDATE, same as any Python/SQLAlchemy script you'd write standalone.
  8. 08
    Backend → Browser
    The function's return {"message": ...} becomes the HTTP response body — FastAPI turns that Python dict into JSON automatically.
  9. 09
    Update the screen
    this.showToast(...) and a re-fetch of the invoice list — nothing here is magic, it's the same fetch pattern as step 2, just reading instead of writing.
04Python side

Inside the Backend

Four FastAPI ideas cover almost everything you'll ever need to read in this codebase.

Routers — one file, one family of endpoints

routers/invoices.py owns everything under /invoices; routers/variations.py owns /projects and /variations. A router file is just a Python module with a bunch of functions, each wearing a decorator that says which URL and verb wakes it up — @router.get("/stats"), @router.post("/upload-excel"). main.py collects every router and mounts them onto the app once, at startup.

Depends() — run this first, then hand me the result

This is the single most-repeated pattern in the codebase:

@router.get("/invoices")
def list_invoices(user=Depends(_auth), db: Session = Depends(get_db)):
    ...

Before list_invoices ever runs, FastAPI runs _auth and get_db first, and hands their return values in as arguments. If _auth raises an error — bad token, no token — list_invoices never runs at all; the request stops right there. It's a gate, not a helper you call manually. Nearly every protected endpoint in SparkBiz starts this exact way.

Pydantic schemas — a bouncer for the JSON body

Before your function sees the request body, Pydantic checks its shape against a class you define — InvoiceCreate, TenantAdminUpdate, and so on. It's what you'd otherwise hand-write as a pile of if not isinstance(...) checks, expressed instead as a plain class with type hints.

SQLAlchemy — SQL wearing a Python costume

You already read and write SQL. This is the exact same thing, spelled differently:

Raw SQL

SELECT *
FROM spkbz_invoices
WHERE tenant_id = 'abc'
  AND status = 'draft';

SQLAlchemy (SparkBiz's actual style)

db.query(Invoice).filter(
    Invoice.tenant_id == 'abc',
    Invoice.status == 'draft'
).all()

Same query, same database round-trip — SQLAlchemy just lets you build it out of Python objects instead of a string, so your editor can catch a typo'd column name before Postgres ever does.

05Where data rests

The Foundation

One shared PostgreSQL database (pgsolutions), every SparkBiz table prefixed spkbz_ so it's easy to spot among any other project's tables sharing the same instance.

The rule that makes multi-tenancy work

There's no separate database per business — one spkbz_invoices table holds every invoice for every SparkBiz customer, side by side. What keeps them from seeing each other's data is a single disciplined habit: every table has a tenant_id column, and every single query filters by it. No exceptions. That's the entire mechanism — not a special database feature, just a convention applied everywhere, all the way down.

For the exhaustive, table-by-table version of this — every column, every relationship — this guide deliberately stops here and hands off to the two reference documents built for exactly that: the entity-relationship diagram and the plain-English database guide, both linked from the admin panel's sidebar.

06Identity

Who Are You — login and tokens

The password is never actually stored

On registration, bcrypt scrambles the password into a one-way hash — there's no function that turns the hash back into the original text, ever, not even for SparkBiz itself. Logging in just re-scrambles what you typed and checks whether the two scrambled versions match. If the database ever leaked, no actual passwords would be in it.

A JWT is a signed, tamper-proof note

On successful login, the backend hands back a token — a long string that encodes, roughly, "this is user abc123, issued at 3:04pm, valid for 7 days" — sealed with a secret key only the backend knows. The browser stores it in localStorage and re-attaches it to every request afterward as the Authorization: Bearer ... header from Level 02.

The clever part: verifying a token is pure math, not a database lookup. FastAPI can check the seal is intact and unexpired without asking Postgres anything — only after that does _auth run the one real query (Level 03, step ③) to fetch the actual user row. Anyone who tampers with a token even slightly breaks the seal, and the check fails instantly.

07Other companies' servers

Beyond the Walls

The backend is a customer of a few outside services too — same wire language (HTTP + JSON), just SparkBiz calling out instead of the browser calling in.

Stripe
Billing. SparkBiz redirects to Stripe's own checkout page, then Stripe calls SparkBiz's backend back (a "webhook") the instant a payment succeeds.
AWS SES / Resend
Two ways to actually send an email; SES is tried first, Resend is the automatic fallback if SES can't deliver.
AWS S3
File storage for E-Sign PDFs and Variation attachments — the actual bytes live there, the database only stores a reference (a key) to where they are.
Google / Microsoft
OAuth — lets a tenant send invoices "from" their own Gmail or Outlook address, with SparkBiz holding a scoped, revocable permission token rather than their password.
08Physical reality

Where It Lives

As of 2026-09-11, a single Railway service does everything — this is Fig. A made concrete:

  • Railway runs the actual uvicorn process (FastAPI) plus Postgres, always on. That same process also serves frontend/ (the app, at app.sparkbiz.com.au) and website/ (the marketing site, at www.sparkbiz.com.au / bare sparkbiz.com.au) as static files — a small middleware picks which one to serve based on which hostname the request arrived on, falling through to the real API routers whenever the path matches a registered route.
  • Netlify used to host frontend/ and website/ as two separate static sites, and there used to be a dedicated api.sparkbiz.com.au domain just for the API — both retired the same day, once the API and frontend were consolidated onto one domain (freeing a Railway custom-domain slot) and the marketing site followed. See CLAUDE.md's DEPLOYMENT section for the full story.

Every frontend page carries a small hostname check — location.hostname === 'localhost' ? 'http://localhost:8000' : 'https://app.sparkbiz.com.au' — so the exact same HTML file works whether it's open on your own machine or live in production, without ever needing a manual find-and-replace before a deploy.

A domain name (app.sparkbiz.com.au) is just a lookup table entry — DNS — pointing at which actual server should answer. Change the DNS record, and the same address now reaches a different machine; the browser doesn't know or care, it just asks "who answers at this name" every time.

09Practical

Field Notes

Three habits that make this whole system legible once you know they're there.

Watch the wire

Chrome DevTools → Network tab. Click any request and you'll see the exact URL, verb, headers, and JSON body/response this whole guide has been describing — live, for real requests, as they happen.

Ask the backend what it can do

Running locally, localhost:8000/docs is FastAPI's auto-generated page listing every single endpoint, with a form to try each one directly — no frontend required. (Disabled in production on purpose.)

Files mirror features

routers/<module>.py almost always maps 1:1 to frontend/<module>.html. Looking for how E-Sign sends an invite? Start in routers/esign.py and esign.html, nowhere else.

Errors show up in two places

A broken request either shows in the browser console (frontend-side JS error) or as a non-200 status in the Network tab with a JSON {"detail": "..."} body (backend-side — that message comes straight from an HTTPException).


Reference

Glossary

API
A set of URLs a backend exposes so other programs — like a browser — can call its functions over the network.
Endpoint
One specific URL + verb combination, e.g. POST /invoices. The API equivalent of one Python function.
HTTP
The agreed-upon phrasing two computers use to make a request and send back a response.
JSON
Dict-shaped plain text — the format requests and responses are written in.
Request / Response
What goes out (verb, URL, headers, maybe a body) and what comes back (status code, headers, maybe a body).
Status code
A three-digit number summarising how the request went: 200s fine, 400s your fault, 500s the server's fault.
Header
Metadata attached to a request or response, separate from the actual body — SparkBiz's key one is the auth token.
Token / JWT
A signed string proving who you are, issued at login, attached to every later request instead of resending a password.
ORM
Object-Relational Mapper — lets you write SQL queries as Python objects/method calls. SQLAlchemy is SparkBiz's ORM.
Router
A Python file grouping related endpoints — invoices.py, variations.py, etc.
Dependency injection
FastAPI's Depends() — running one function first and feeding its result into another.
Schema
A Pydantic class describing the exact shape a request body must have.
CORS
A browser security rule about which websites are allowed to call which APIs; the backend has to explicitly allow SparkBiz's own domains.
Webhook
The reverse of a normal API call — an outside service (like Stripe) calling into SparkBiz's backend to report something happened.
Migration
A SQL script that changes the database's structure (adds a column, a table) to match what the Python models now expect.
Environment variable
A secret or setting (API keys, database URL) kept outside the code, read at startup — never committed to git.
Deploy
Pushing new code to Railway/Netlify so the live app actually runs the latest version.