16 API Contract
Purpose of this page
Define the backend API in a way that:
- keeps the frontend and backend cleanly separated (scales better than tightly coupled server-rendered pages), enables a premium PWA UX (fast search, instant updates), supports offline-friendly caching (client can sync snapshots), and remains portable across hosting providers.
- We're using NestJS + Prisma + Postgres because it gives us:
- a structured codebase that scales (Nest modules),
- typed data contracts (TypeScript),
- reliable data constraints and query performance (Postgres),
- and a smooth evolution path (search, workers, sharing, seller features, AI jobs).
API design principles (why this shape vs other styles)
- REST-first, boring-on-purpose We'll use REST endpoints with predictable patterns because:
- simplest to implement and debug,
- easy for caching and offline sync,
- works great with mobile/PWA clients,
- easy to document and test.
- (We could do GraphQL later, but it's not necessary and adds complexity early.)
- User-scoped resources
- All data is private per user. Every endpoint enforces:
- authentication
- authorization (user can only access their own items/locations/tags)
- Fast UI > chatty API
- Where it matters (Home search, location detail), we use endpoints that return enough data to render the screen without multiple round trips.
- Future-proof for offline and sync
- We include:
- updatedAt timestamps
- cursor or since parameters
- So the client can sync deltas later.
Base URL & versioning
Base: /api/v1
Versioning via path keeps it explicit and safe for future changes.
Authentication (web/PWA best practice)
Strategy
Access token + refresh token in httpOnly secure cookies
Frontend does not store tokens in localStorage (reduces XSS risk). Backend provides endpoints for login/refresh/logout. Why cookies vs pure JWT headers? For web/PWA, httpOnly cookies are safer and easier. If we ship native apps later, we can add token-based auth in parallel.
Standard conventions
Common headers
Content-Type: application/json
Accept: application/json
X-CSRF-Token: required on POST/PUT/PATCH/DELETE (obtain via GET /auth/csrf)
X-Request-ID: optional inbound, backend echoes or generates for traceability
Standard response shape (success)
We keep it simple:
- Return the resource(s) directly.
- For list endpoints return:
- data array
- meta object
Standard error shape
Why a consistent error shape? Frontend can display errors reliably. It's easier to debug and test.
Data models (API DTOs)
Location DTO
1{
2"error": {
3"code": "VALIDATION_ERROR",
4"message": "Item name is required",
5"details": { "field": "name" }
6}
7}
1{
2"id": "loc_123",
Item DTO
Tag DTO
Note: IDs can be UUIDs; the loc_123 format is illustrative.
Endpoints
16.1 Auth
POST /auth/register
Create account.
Body
Response: 201 + sets auth cookies.
POST /auth/login
Login and set cookies.
Body
{
"email": "user@example.com",
"password": "hunter2"
}
Response
200 OKwith the authenticated user payload (see/auth/me) when two-factor is disabled.200 OKwith a challenge descriptor when two-factor is enabled:
{
"twoFactorRequired": true,
"challengeToken": "uuid-like",
"expiresAt": "2026-01-16T12:00:00.000Z",
"methods": ["totp", "recovery"]
}
The frontend must call POST /auth/2fa/challenge with the challengeToken plus either a 6-digit TOTP (code) or recovery code (recoveryCode) to finish the login. The backend sets cookies only after successful challenge verification.
POST /auth/oauth/:provider
Exchange an ID token from Google/Microsoft/Apple for our session cookies. :provider must match the backend enum (GOOGLE, MICROSOFT, APPLE). Requests are rate limited and return 400 OAUTH_PROVIDER_DISABLED when a provider is not configured for the environment.
Body
{
"idToken": "<provider-issued JWT>"
}
Response
200 OK + sets cookies + returns the same payload as /auth/login.
Notes
- Frontend must retrieve the signed ID token via the provider SDK (Google Identity Services, MSAL.js, Sign in with Apple JS) before calling this endpoint.
- If the provider omits an email address the API responds with
400 OAUTH_EMAIL_REQUIREDbecause we cannot create/link an account without one.
POST /auth/2fa/challenge
Complete a pending login challenge using either a TOTP code or a one-time recovery code.
Body
{
"challengeToken": "uuid-like",
"code": "123456" // or "recoveryCode": "ABCD-EFGH-IJKL"
}
Response
200 OK + sets auth cookies + returns the same payload as /auth/me.
Errors:
401 TWO_FACTOR_CHALLENGE_EXPIRED— user must start over with/auth/login.401 TWO_FACTOR_CODE_INVALID— invalid authenticator or recovery code.
GET /auth/2fa/status
Return the user's two-factor status.
Response
{
"enabled": true,
"enabledAt": "2026-01-16T10:22:04.000Z",
"pendingSetup": false,
"setupExpiresAt": null,
"backupCodesRemaining": 6
}
POST /auth/2fa/setup
Begin a new two-factor setup flow. Returns a secret, otpauth:// URL, temporary recovery codes, and an expiresAt. User must confirm within ~15 minutes.
POST /auth/2fa/verify
Confirm the pending setup by submitting a valid 6-digit TOTP. Response mirrors GET /auth/2fa/status once two-factor is enabled.
POST /auth/2fa/recovery-codes
Regenerate recovery codes after verifying with an authenticator or existing recovery code.
Body
{
"code": "123456"
}
or
{
"recoveryCode": "OLD1-USED-CODE"
}
Response
{
"recoveryCodes": ["NEWA-CODE-HERE", "NEWA-CODE-2", "..."]
}
POST /auth/2fa/disable
Disable two-factor authentication after verifying password and an authenticator or recovery code.
Body
{
"currentPassword": "hunter2",
"code": "123456"
}
Response
204 No Content. All refresh tokens are revoked, forcing re-login across devices.
POST /auth/refresh
Rotate access token using refresh cookie.
Body: empty
GET /auth/csrf
Issue a fresh CSRF token and set the double-submit cookie used for subsequent state-changing requests.
Response
200 { "csrfToken": "..." }
3"name": "Box 3",
4"parentId": "loc_100",
5"pinned": false,
6"createdAt": "2025-12-26T10:00:00Z",
7"updatedAt": "2025-12-26T10:00:00Z"
8}
1{
2"id": "item_123",
3"name": "USB-C adapter",
4"locationId": "loc_123",
5"quantity": 1,
6"notes": "In the small pouch",
7"photoUrl": "https://...",
8"tags": ["cables", "travel"],
9"createdAt": "2025-12-26T10:00:00Z",
10"updatedAt": "2025-12-26T10:00:00Z"
11}
1{
2"id": "tag_123",
3"name": "travel",
4"createdAt": "2025-12-26T10:00:00Z",
5"updatedAt": "2025-12-26T10:00:00Z"
6}
1{ "email": "user@example.com", "password": "..." }
1{ "email": "user@example.com", "password": "..." }
POST /auth/logout
Clear cookies.
Body: empty
GET /auth/me
Returns current user profile.
Note: earlier drafts used GET /me. The current implementation uses /auth/me.
Response
16.2 Locations
POST /locations
Create location.
Body
Rules
sibling uniqueness under the same parent (case-insensitive) name required
GET /locations
List locations.
Query params
tree=true|false (default false) updatedSince (optional, ISO timestamp for delta sync)
Response (flat)
Response (tree=true) returns nested:
- Why offer tree vs flat?
1{ "id": "user_1", "email": "user@example.com" }
1{ "name": "Cellar", "parentId": null, "pinned": false }
1{
2"data": [ { "id": "...", "name": "...", "parentId": null } ],
3"meta": { "count": 42 }
4}
1{
2"data": [
3{
4 "id": "loc_root",
5 "name": "Home",
6 "children": [
7 { "id": "loc_child", "name": "Cellar", "children": [] }
8 ]
9}
10],
11"meta": { "count": 42 }
12}
Flat is simpler for syncing and caching. Tree is convenient for certain UI screens. We can implement tree by building it server-side or client-side from flat data.
GET /locations/: id
Get location detail (recommended “screen endpoint”).
Response
ItemSummaryDTO is ItemDTO without heavy fields (e.g., notes) to keep it light.
PATCH /locations/: id
Update location.
Body
DELETE /locations/: id
Delete location.
Behavior
If location has items or children → 409 Conflict with guidance
POST /locations/: id/move-contents
Guided helper endpoint (optional MVP but recommended). Moves all items (and optionally children) to another location.
Body
1{
2"location": { ...LocationDTO... },
3"breadcrumb": [
4{ "id": "loc_root", "name": "Home" },
5{ "id": "loc_cellar", "name": "Cellar" }
6],
7"children": [ ...LocationDTO... ],
8"items": [ ...ItemSummaryDTO... ]
9}
1{ "name": "Cellar (Left)", "pinned": true }
1{
2"error": {
3"code": "LOCATION_NOT_EMPTY",
4"message": "Location contains items or sub-locations. Move contents
before deleting.",
5"details": { "itemsCount": 12, "childrenCount": 3 }
6}
7}
1{ "targetLocationId": "loc_target", "includeChildren": false }
16.3 Items
POST /items
Create item.
Body
Rules
name required location required quantity >= 1 tags optional; create-if-missing behavior is allowed
POST /items/import/csv
Bulk import items from CSV data. Requires dataImport feature flag enabled (PRO plan or above).
Body
{
"csvData": "name,location,quantity\nWidget,Garage,5\nGadget,Office,2",
"mappings": [
{ "columnIndex": 0, "columnName": "name", "field": "name" },
{ "columnIndex": 1, "columnName": "location", "field": "location" },
{ "columnIndex": 2, "columnName": "quantity", "field": "quantity" }
],
"skipFirstRow": true,
"defaultLocationId": "loc_123"
}
Supported field mappings
| Field | Description |
|---|---|
name | Item name (required - at least one column must map to name) |
location | Location name - will be matched or created |
quantity | Quantity (defaults to 1) |
notes | Item notes |
tags | Comma or semicolon separated tag names |
status | Item status (OWNED, WANTED, SOLD, etc.) |
purchasePrice | Purchase price (will be converted to cents) |
purchaseDate | Purchase date (ISO format or common date formats) |
skip | Skip this column |
Response
{
"success": true,
"imported": 50,
"skipped": 2,
"errors": [
{ "row": 15, "error": "Missing required field: name" }
]
}
Errors
403 FEATURE_DISABLED: User's plan does not includedataImportfeature.400 VALIDATION_ERROR: Invalid CSV data or mapping configuration.400 LIMIT_EXCEEDED: Import would exceed item or location limits.
GET /items/export/csv
Export all user items as a CSV file. Requires dataExport feature flag enabled (PRO plan or above).
Response
Returns a CSV file download with columns:
- Name, Location (full path), Quantity, Status
- Purchase Price, Purchase Date, Purchase Source
- Tags (comma-separated), Notes
- Created At, Updated At
Headers
Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="inventory-export-YYYY-MM-DD.csv"
Errors
403 FEATURE_DISABLED: User's plan does not includedataExportfeature.
GET /items/: id
Get item detail (includes breadcrumb and the current activity history summary/timeline payload used on the item detail screen).
Response
PATCH /items/: id
Update item fields.
Body
PATCH /items/: id/sections/visibility
Toggle which sections are visible for a specific item. This endpoint lets a user override their account-level layout preferences on an item-by-item basis.
Body
{
"sections": [
"showMaintenanceSection",
"showSaleTrackerSection",
"showBorrowerTrackerSection",
"showTagsSection"
]
}
sectionsaccepts any subset of the keys exposed byItemSectionSettings.- Send
nullto delete overrides and fall back to the account defaults.
Response
Returns the updated Item payload (same shape as GET /items/:id) with the
sectionVisibilityOverrides array refreshed.
DELETE /items/: id
Delete item (confirmation handled in UI).
16.4 Move Item
POST /items/: id/move
Move item to another location.
Body
1{
2"targetLocationId": "loc_999"
3}
1{
2"item": { ...ItemDTO... },
3"breadcrumb": [
4{ "id": "loc_root", "name": "Home" },
5{ "id": "loc_cellar", "name": "Cellar" },
6{ "id": "loc_box3", "name": "Box 3" }
7]
8}
1{
2"name": "USB-C to HDMI adapter",
3"quantity": 2,
4"notes": "One spare",
5"tags": ["cables", "travel"]
6}
Response
updated item + breadcrumb Why a dedicated move endpoint? Makes audit/history easy later (item_location_history). Keeps intent explicit and easier to track.
16.5 Search (core)
GET /search
Search items (and optionally locations).
Query params
q (string, required) types=items,locations (default: items) limit (default 20) cursor (optional, for pagination) includeBreadcrumb=true|false (default true)
Response
Item results match against item name, notes, tags, direct location name, and ancestor breadcrumb segments inside the authenticated user scope.
Why a unified search endpoint?
Home screen is search-led.
One call returns everything the UI needs.
Later we can tune ranking without changing the client.
Dashboard search renders both item and location matches from this endpoint when available.
When the network is unavailable or a search request fails with a network error, the dashboard falls back to the cached IndexedDB snapshot for read-only search results only when the current plan includes the offlineMode entitlement.
If the current plan does not include offline mode, the dashboard stays online-only and shows a recovery message instead of serving cached search results.
16.6 Tags (optional endpoints)
We can keep tags implicit via item create/update. If we want explicit tag management later:
GET /tags
List tags (for autocomplete).
1{ "targetLocationId": "loc_999" }
1{
2"data": [
3{
4 "type": "item",
5 "item": { "id": "item_1", "name": "USB-C adapter", "locationId":
- "loc_3" },
6 "breadcrumb": [
7 { "id": "loc_root", "name": "Home" },
8 { "id": "loc_3", "name": "Box 3" }
9 ],
10 "rank": 1
11}
12],
13"meta": { "count": 1, "nextCursor": null }
14}
POST /tags
Create tag.
16.7 Uploads (photos)
POST /uploads/items/: id/photo
Upload or replace item photo.
Multipart/form-data
Returns photoUrl
16.8 Subscription & billing
GET /me/subscription
Return the current subscription summary plus the effective plan policy for the authenticated user.
POST /payments/checkout-sessions
Create a Stripe Checkout session for a new self-serve paid subscription.
Body
{
"plan": "PRO", // or "TEAM"
"billingPeriod": "monthly",
"startTrial": true
}
Rules
- Self-serve checkout accepts only
PROandTEAMbecause those are the only paid plans in the current runtime catalog. - Current self-serve checkout uses monthly pricing only. Legacy annual subscriptions remain supported only for grandfathered renewal compatibility.
POST /payments/checkout-sessions/confirm
Confirm a completed Stripe Checkout session and refresh the authenticated user's subscription summary.
POST /me/subscription/change-plan
Change an existing Stripe-managed subscription to another self-serve plan.
Body
{
"targetPlan": "TEAM" // or "PRO"
}
Rules
- Self-serve plan changes accept only
PROandTEAMbecause those are the only paid plans in the current runtime catalog.
Why separate upload endpoint? Keeps item create fast and simple. Allows retry on photo upload failure without losing item.
Pagination & filtering conventions
Lists use limit + cursor pagination where needed. Location detail returns a bounded items list (limit + optional pagination if large). For MVP, most lists will be short; we add pagination as inventories grow.
Status codes (standard)
200 OK (read/update)
201 Created (create)
204 No Content (delete)
400 Validation error
401 Unauthorized
403 Forbidden
404 Not found
409 Conflict (e.g., location not empty)
500 Unexpected server error
How this API supports offline-friendly UX
Client can fetch full inventory snapshot (GET /locations flat + GET /items list if we add it) and store in IndexedDB. updatedSince supports delta sync later. Search can be performed locally against cached data when offline; server search remains the source of truth when online. Optional endpoint (recommended for offline sync, even in MVP):
GET /sync/snapshot
Returns compact lists of locations/items/tags updated since a timestamp. ?updatedSince=...
Frontend screen coverage (implemented)
This table documents the currently implemented screen-to-endpoint wiring.
- Frontend uses the single API wrapper in frontend/lib/api.ts.
- All state-changing requests (POST/PATCH/DELETE) automatically fetch and attach CSRF via GET /auth/csrf and the X-CSRF-Token header.
| Screen / Route | Frontend entrypoint | Frontend API calls | Backend endpoints (base: /api/v1) |
|---|---|---|---|
Login (/auth/login) | frontend/app/auth/login/page.tsx | login(), fetchProfile() | POST /auth/login, GET /auth/me |
Register (/auth/register) | frontend/app/auth/register/page.tsx | register(), loginWithProvider(), fetchProfile() | POST /auth/register (requires meetsMinimumAge = true), POST /auth/oauth/:provider (requires meetsMinimumAge = true when creating a new OAuth-backed account), GET /auth/me |
Dashboard (/dashboard) | frontend/app/dashboard/page.tsx | fetchItemsList() (favorites), searchInventory() with entitlement-gated cached offline fallback, fetchLocations(), updateItem(), moveItem() | GET /items/table, GET /search, GET /locations, PATCH /items/:id, POST /items/:id/move |
Locations (/locations) | frontend/app/locations/page.tsx | fetchLocationTree(), fetchLocations(), createLocation() | GET /locations?tree=true, GET /locations, POST /locations |
Location detail (/locations/:locationId) | frontend/app/locations/[locationId]/page.tsx | fetchLocation(), fetchLocations(), fetchLocationTree(), updateLocation(), deleteLocation(), createLocation() (sub-location) | GET /locations/:id, GET /locations, GET /locations?tree=true, PATCH /locations/:id, DELETE /locations/:id, POST /locations |
Items list (/items) | frontend/app/items/page.tsx | fetchItemsList(), fetchLocations(), fetchTags(), updateItem() | GET /items/table, GET /locations, GET /tags, PATCH /items/:id |
New item (/items/new) | frontend/app/items/new/page.tsx | fetchLocationTree(), fetchTags(), createItem(), requestItemPhotoUpload(), confirmItemPhoto() | GET /locations?tree=true, GET /tags, POST /items, POST /uploads/items/:itemId/photo/presign, POST /uploads/items/:itemId/photo/confirm |
Item detail (/items/:itemId) | frontend/app/items/[itemId]/page.tsx | fetchItem(), fetchLocationTree(), fetchTags(), fetchItemPhotoUrl(), requestItemPhotoUpload(), confirmItemPhoto(), deleteItemPhoto(), updateItem(), moveItem(), updateItemSectionVisibility(), deleteItem() | GET /items/:id, GET /locations?tree=true, GET /tags, GET /uploads/items/:itemId/photo, POST /uploads/items/:itemId/photo/presign, POST /uploads/items/:itemId/photo/confirm, DELETE /uploads/items/:itemId/photo, PATCH /items/:id, POST /items/:id/move, PATCH /items/:id/sections/visibility, DELETE /items/:id |
Onboarding (/onboarding) | frontend/app/onboarding/page.tsx | fetchLocations(), fetchItemCount(), createLocation(), createItem() | GET /locations, GET /items (count), POST /locations, POST /items |
Settings (/settings) | frontend/app/settings/page.tsx | changePassword(), deleteAccount(), logout() | POST /auth/change-password, DELETE /auth/account, POST /auth/logout |
Reminder settings (/settings/reminders) | frontend/app/settings/reminders/page.tsx | fetchReminderOverview(), updateReminderSettings() | GET /reminders/overview, PATCH /reminders/settings |
Subscription settings (/settings/subscription) | frontend/app/settings/subscription/page.tsx | fetchSubscriptionDetails(), createCheckoutSession(), cancelSubscription(), resumeSubscription(), changeSubscriptionPlan() | GET /me/subscription, POST /payments/checkout-sessions (PRO or TEAM, monthly only), POST /me/subscription/cancel, POST /me/subscription/resume, POST /me/subscription/change-plan (PRO or TEAM) |
Billing success (/billing/success) | frontend/app/billing/success/page.tsx | confirmCheckoutSession() | POST /payments/checkout-sessions/confirm |
Trial reminder unsubscribe (/unsubscribe/trial-reminders) | frontend/app/unsubscribe/trial-reminders/page.tsx | unsubscribeTrialReminderEmails() | GET /reminders/unsubscribe/trial |
Import (/settings/import) | frontend/app/settings/import/page.tsx | importItemsFromCsv(), fetchLocations() | POST /items/import/csv, GET /locations |
Open questions (to decide before implementation)
Do we want a dedicated GET /items list endpoint (useful for caching/sync)? Tree building: server returns tree or frontend builds it from flat list? Should tags be fully implicit (only through items) in MVP?