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 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.
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.
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.
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.
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.
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.
routers/email_send.py running
top to bottom, same as any other function call.
The same trip, in prose
-
01
ClickAlpine's
@clickrunssendOne(id, number)ininvoicing.html. -
02
Browser → Backend
fetch(`${API}/email/send/${id}`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } })— the token is whatever login saved intolocalStorage. -
03
Who is this?FastAPI runs
_authbefore your function even starts. It unpacks the token (pure math — see Level 06), then runs one real query:SELECT ... FROM spkbz_users WHERE id = .... -
04
Load the invoice
send_invoice()queries theInvoiceandTenantrows — ordinary SQLAlchemy, ordinarySELECTs. -
05
Build the PDF
generate_invoice_pdf()inservices/pdf_generator.py— plain Python (ReportLab), no network call, no DB call. It's just a function that returns bytes. -
06
Backend → Email provider
_send_invoice()hands off to one shared helper —send_via_default_channel()inservices/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 theEmailSendLogtable. 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 fromemail_signature_html()inservices/email_signature.py(Best Regards, contact details, logo, footer note, footer logos), andservices/inline_images.pyembeds 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. -
07
Mark it sent
inv.status = SENT; db.commit()— oneUPDATE, same as any Python/SQLAlchemy script you'd write standalone. -
08
Backend → BrowserThe function's
return {"message": ...}becomes the HTTP response body — FastAPI turns that Python dict into JSON automatically. -
09
Update the screen
this.showToast(...)and a re-fetch of the invoice list — nothing here is magic, it's the samefetchpattern as step 2, just reading instead of writing.
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.
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.
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.
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.
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.
Where It Lives
As of 2026-09-11, a single Railway service does everything — this is Fig. A made concrete:
- Railway runs the actual
uvicornprocess (FastAPI) plus Postgres, always on. That same process also servesfrontend/(the app, atapp.sparkbiz.com.au) andwebsite/(the marketing site, atwww.sparkbiz.com.au/ baresparkbiz.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/andwebsite/as two separate static sites, and there used to be a dedicatedapi.sparkbiz.com.audomain 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. SeeCLAUDE.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.
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).
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.