GET /projects/loven200 OK
LOVEN
// an art marketplace app that brings local artists into one place
- my role
- Backend lead, and the Flutter HTTP client
- team
- 5 engineers
- period
- Mar 2026 – Jul 2026
- stack
- Python, Flask, PostgreSQL, SQLAlchemy, Alembic, Firebase, Moyasar, Docker, Flutter
- status
- In development. The API runs on a Render staging environment, with payments in Moyasar test mode.
The problem
Local artists in Saudi Arabia are scattered across Instagram, TikTok, Haraj and other platforms. That makes them hard to discover, and makes buying their work harder still. LOVEN brings them into one platform, where artists can show their work, either for sale or just to share it, and art lovers can discover and buy it in one place.
What I built
- The REST API: 62 endpoints across 14 modules, on a 13-table PostgreSQL schema
- Authentication with Firebase and our own JWT sessions
- Payment verification with Moyasar
- A central notification service, with in-app and push notifications, and scheduled reminders
- The Flutter HTTP client that connects the app to the API
Architecture
flowchart TB
App["Flutter app"] -->|"HTTPS + JWT"| R
subgraph API["Flask REST API (/api/v1)"]
R["Routes"] --> F["Facades"] --> S["Services"] --> Rep["Repositories"] --> M["Models"]
end
M --> PG[("PostgreSQL")]
S --> EXT
subgraph EXT["External services"]
FB["Firebase: Auth, Storage, Cloud Messaging"]
MY["Moyasar payments"]
end
The API is split into layers, and each layer has one job. Routes handle HTTP: they check the request and shape the response. Facades hold the business logic for each feature and coordinate the services and repositories it needs. Repositories talk to the database. External services, like Moyasar and Firebase, live behind their own clients, so they can be replaced or mocked in tests without touching the rest of the code.
Journey 1: Signing in
sequenceDiagram
participant App as Flutter app
participant FB as Firebase Auth
participant API as LOVEN API
participant DB as PostgreSQL
App->>FB: Sign up
FB-->>App: ID token + verification email
App->>API: POST /auth/firebase/register-sync
Note over API: Verify ID token
API->>DB: Create user + profile
API-->>App: 201 Created, no session yet
Note over App,FB: User verifies email
App->>FB: Sign in
FB-->>App: ID token
App->>API: POST /auth/firebase/login
Note over API: Verify token + email
API->>DB: Find user, check active
API-->>App: Access + refresh tokens
Decisions
- Why Firebase? We wanted every account to have a verified email, so nobody signs up with a fake one. We already used Firebase for image storage and push notifications, so we moved sign-in there too. Firebase now owns passwords and verification emails, and our database stores no passwords.
- Why our own JWT on top? The API stays independent of Firebase on every request, and the token carries the user's role in our system.
- No session before verification. Registering creates the account, but the API only issues a session after the email is verified.
- Roles are allowlisted at sign-up. Users can register as a customer or an artist, and nothing else, so nobody can sign themselves up as an admin.
- Migrating without losing anyone. Accounts from the old password-based sign-in are linked automatically on first Firebase login, and the old endpoints return
410 Gone.
Journey 2: Paying for an order
sequenceDiagram
participant App as Flutter app
participant API as LOVEN API
participant M as Moyasar
participant DB as PostgreSQL
App->>API: POST /payments/orders/{id}/initiate
API->>DB: Create pending payment
API-->>App: Expected amount (halalah)
App->>M: Pay with Moyasar SDK
Note over App,M: Card details go to Moyasar only
M-->>App: Payment ID
App->>API: POST /payments/orders/{id}/verify
API->>M: Fetch payment (secret key)
M-->>API: Status, amount, currency
Note over API: Compare with order total
API->>DB: Mark paid, fulfill order
API-->>App: 200 Payment verified
Decisions
- Card details never touch our server. The app sends them straight to Moyasar, which keeps our server out of handling card data.
- The server never trusts the app. It asks Moyasar directly, and fulfills the order only if the status, currency and amount match the order total exactly. A modified app can't pay less.
- Money is never a float. Amounts are handled as
Decimaland compared in halalah as whole numbers, so there are no rounding errors. - Safe to retry. Verifying the same payment twice returns the same result, a payment can't be reused for a second order, and a database constraint settles two verifications that arrive at the same moment.
- When Moyasar is slow or down, the request times out, the API returns
502, and the payment stays pending, so the app can safely try again.
Journey 3: Notifications
flowchart TB
E["Order or payment event"] --> S["Notification service"]
J["Scheduled job"] --> S
S --> D{"Already sent?"}
D -->|"Yes"| X["Skip"]
D -->|"No"| DB[("Save in-app notification")]
DB --> P["Push via Firebase Cloud Messaging"]
Decisions
- One service owns every notification, across 7 event types: welcome, new order, order status, payment success, feedback, and two reminders.
- The database first, then push. The in-app notification is the source of truth. If a push doesn't arrive, the user still finds it in the app.
- No duplicates. Each notification is unique per user, type and order, so retries and repeated jobs never notify anyone twice.
- A clear contract with the app. Every push carries a type and an action, like
open_order_details, so Flutter knows which screen to open. - Scheduled reminders for paid orders waiting on the artist for 24 hours, and for carts left untouched for 24 hours, run as command-line jobs.
Connecting the app: staying signed in
sequenceDiagram
participant S as App screens
participant C as ApiClient
participant API as LOVEN API
S->>C: 5 requests at the same time
C->>API: Requests (expired token)
API-->>C: 401 Unauthorized
C->>API: POST /refresh (only once)
API-->>C: New access token
C->>API: Retry each request once
API-->>C: 200 OK
I wrote the Flutter HTTP client that sits between every screen and the API. It attaches the token to each request, and when the token expires, it refreshes it once and retries the original request, so the user never notices. When several requests fail at the same moment, they all wait for a single refresh instead of sending five. Tokens are kept in the phone's secure storage (Keychain on iOS, Keystore on Android), and the user's role is never stored on the device.
Testing and deployment
- 44 unit tests for authentication, order access and notifications
- The API runs in Docker with Gunicorn, and applies database migrations before it starts serving
- Our team deployed it on Render as a staging environment
Before launch
- Payment webhooks from Moyasar, and a job that checks pending payments
- Automatic refunds for duplicate payments
- Revoking tokens on sign-out
- Scheduling the reminder jobs
- Moving push notifications to a background queue
What I learned
- Never trust the client with anything that involves money.
- Design every important request to be safe to repeat.
- A clear contract between the backend and the app saves more time than any clever code.