Back to docs hub·docs/architecture/16-api-contract.md
Reference document

16 API Contract

Last updated Mar 23, 2026

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)

  1. 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.)
  1. User-scoped resources
  • All data is private per user. Every endpoint enforces:
  • authentication
  • authorization (user can only access their own items/locations/tags)
  1. 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.
  1. 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 OK with the authenticated user payload (see /auth/me) when two-factor is disabled.
  • 200 OK with 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_REQUIRED because 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

FieldDescription
nameItem name (required - at least one column must map to name)
locationLocation name - will be matched or created
quantityQuantity (defaults to 1)
notesItem notes
tagsComma or semicolon separated tag names
statusItem status (OWNED, WANTED, SOLD, etc.)
purchasePricePurchase price (will be converted to cents)
purchaseDatePurchase date (ISO format or common date formats)
skipSkip 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 include dataImport feature.
  • 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 include dataExport feature.

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"
	]
}
  • sections accepts any subset of the keys exposed by ItemSectionSettings.
  • Send null to 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 PRO and TEAM because 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 PRO and TEAM because 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 / RouteFrontend entrypointFrontend API callsBackend endpoints (base: /api/v1)
Login (/auth/login)frontend/app/auth/login/page.tsxlogin(), fetchProfile()POST /auth/login, GET /auth/me
Register (/auth/register)frontend/app/auth/register/page.tsxregister(), 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.tsxfetchItemsList() (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.tsxfetchLocationTree(), fetchLocations(), createLocation()GET /locations?tree=true, GET /locations, POST /locations
Location detail (/locations/:locationId)frontend/app/locations/[locationId]/page.tsxfetchLocation(), 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.tsxfetchItemsList(), fetchLocations(), fetchTags(), updateItem()GET /items/table, GET /locations, GET /tags, PATCH /items/:id
New item (/items/new)frontend/app/items/new/page.tsxfetchLocationTree(), 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.tsxfetchItem(), 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.tsxfetchLocations(), fetchItemCount(), createLocation(), createItem()GET /locations, GET /items (count), POST /locations, POST /items
Settings (/settings)frontend/app/settings/page.tsxchangePassword(), deleteAccount(), logout()POST /auth/change-password, DELETE /auth/account, POST /auth/logout
Reminder settings (/settings/reminders)frontend/app/settings/reminders/page.tsxfetchReminderOverview(), updateReminderSettings()GET /reminders/overview, PATCH /reminders/settings
Subscription settings (/settings/subscription)frontend/app/settings/subscription/page.tsxfetchSubscriptionDetails(), 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.tsxconfirmCheckoutSession()POST /payments/checkout-sessions/confirm
Trial reminder unsubscribe (/unsubscribe/trial-reminders)frontend/app/unsubscribe/trial-reminders/page.tsxunsubscribeTrialReminderEmails()GET /reminders/unsubscribe/trial
Import (/settings/import)frontend/app/settings/import/page.tsximportItemsFromCsv(), 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?