FAQ
For developers picking up this codebase.
Setup / local dev
How do I run this locally?
uv sync
cp .env.example .env # fill in the values, see below
createdb fundity_poc # or point DATABASE_URL at an existing DB
uv run alembic upgrade head
uv run uvicorn app.main:app --app-dir src --reload --port 8000
curl http://localhost:8000/api/v1/health
Do I need a real Termii account to develop locally?
No. Set TERMII_MODE=mock in .env and leave the Termii credentials blank. Every SMS/OTP-dependent endpoint (/sms/send, /auth/otp/request, /auth/otp/verify, the inbound webhook's outbound reply) works against a deterministic fake provider — no network calls, no real credentials needed. The generated OTP PIN is logged as [MOCK OTP] ... pin_id=....
How do I run the test suite?
TERMII_MODE=mock uv run pytest tests/ -v
Mock mode is required for the auth/OTP tests since they don't hit real Termii (tests/conftest.py sets this as a default automatically, so you don't usually need to pass it by hand). The encryption, PIN-hashing, and duplicate-protection tests don't touch Termii at all and pass under any mode.
Where's the API reference?
/api/docs (Swagger UI) — moved off the default /docs path so this documentation site could use it instead.
Production
Is this actually deployed anywhere, or just local/CI?
Live at https://offline.getfundity.com as of 2026-11-04, running TERMII_MODE=live with real credentials — not a mock deployment. See /status for current open items and the README's "Production deployment" section for the deploy pipeline itself.
Does the Flutter app talk to my local server or production?
Depends on its .env: OFFLINE_BACKEND_BASE_URL points at whichever one you've configured — https://offline.getfundity.com for production, http://localhost:8000 (or similar) for local dev. PAYLOAD_ENCRYPTION_KEY must match whichever backend it's pointed at exactly, or nothing decrypts.
Termii questions
Why does an outbound send fail with SENDER_ID_NOT_APPROVED?
Your Termii workspace has no approved Sender ID yet — check TERMII_SENDER_ID in .env against what's actually approved in the Termii dashboard (Settings → Sender ID). Until approval lands, requests automatically retry against TERMII_FALLBACK_SENDER_ID; if that's also unapproved for your workspace, sends fail until at least one Sender ID is approved, or Termii's dashboard "Sandbox" toggle is enabled.
What's the difference between TERMII_MODE=sandbox and TERMII_MODE=live?
None, from this codebase's perspective — both make real HTTP calls to Termii with real credentials. sandbox exists purely as a label (shows up in logs and could inform future logic) for "this is non-prod traffic." The thing that actually relaxes Sender ID requirements is Termii's own account-level Sandbox toggle in their dashboard, which isn't something this codebase controls.
OTP not received — what do I check?
- Mock mode: check the server logs for
[MOCK OTP] ... pin_id=.... - Live mode: is there an approved Sender ID? Check Termii's dashboard delivery report (DLR) for the actual
pin_idif Termii shows it as sent but the device never got it, since that's a carrier/delivery issue outside this codebase.
Auth questions
Why do I get 401 immediately after logging in?
Access tokens are short-lived (15 minutes by default, ACCESS_TOKEN_TTL_MINUTES). Use POST /auth/refresh with the refresh token to get a new pair — and note refresh tokens rotate on every use, so always store the newest refresh token from the response, not the one you started with.
I refreshed twice with the same refresh token and got 401 the second time — bug?
No — that's rotation working as intended. Each refresh token is single-use; reusing an already-consumed one is rejected. If your client needs to refresh from multiple places, make sure they're not racing each other with the same stale token.
Is the offline-transfer PIN the same as the OTP login PIN?
No, and this is intentional — see /docs/architecture. The OTP PIN authenticates who you are (login) and is owned by Termii, which generates and verifies it server-side; we only ever hold its opaque pin_id. The transaction PIN authorizes a specific transfer and is always hashed/verified locally with PBKDF2 — it never touches Termii at all, and now has its own attempt lockout (see /docs/security).
Testing on a real device
How do I test the offline transfer with a real phone + SIM?
Against production (no ngrok needed — the webhook is already registered at https://offline.getfundity.com/api/v1/webhook/sms/inbound):
- Point the Flutter app's
OFFLINE_BACKEND_BASE_URLathttps://offline.getfundity.comandPAYLOAD_ENCRYPTION_KEYat the matching production value. - In the Flutter app, confirm
OFFLINE_SMS_DESTINATION_NUMBERmatches whichever number Termii provisions alongside the currently-active Sender ID (may still be the shared fallback, see/status). - Build and run on a physical Android device (SMS send/receive is Android-only — see
/docs/implementation).
Against local dev instead:
- Get a Termii Sender ID approved (or enable their Sandbox toggle).
- Set
TERMII_MODE=liveand fill in real Termii credentials. ngrok http 8000, register the ngrok URL +/api/v1/webhook/sms/inboundon Termii's dashboard (as a second, temporary webhook — don't overwrite the production one).- Steps 2–3 above.
Can I test without a physical device, using an emulator?
Android emulators can send SMS but the emulator's own "phone number" is a fake sender/receiver pair the emulator console assigns — real Termii delivery to an emulator doesn't work the way it does to a real SIM. For emulator-based testing, use TERMII_MODE=mock on the backend and drive the Flutter app's OfflineTransferService methods directly in a test harness rather than relying on real SMS transport through the emulator.