All documents

MERIDIAN · DOC 05 / 25

API

The /api/v1 groups, the implemented auth endpoints, envelopes, and the error code registry.

Base path: /api/v1. Versioned from day one so contracts can evolve without breaking deployed clients.


1 · Endpoint groups

GroupAudienceAuthState
/authboth adminsIMPLEMENTED (Phase 3): login · logout · me · change-password
/publicend customersnoIMPLEMENTED (Phase 6): public tracking — see §2d
/admintenant adminsyesIMPLEMENTED (Phase 5): package management — see §2c
/platformplatform adminyesIMPLEMENTED (Phase 4): tenant management — see §2b

2 · Implemented endpoints — authentication (Phase 3)

All auth endpoints sit behind the standard pipeline (rate limit, origin check, security headers). Responses carry no credential material ever.

POST /api/v1/auth/login

Authenticates a user and creates a server-side session.

Auth requiredno
Rate limit10 attempts / IP / 10 min (in addition to the global limit)
Body{ "email": "ops@swift.example.com", "password": "…" }
Success 200{ "success": true, "message": "Login successful.", "data": { "user": { "id", "name", "email", "role", "tenantId" } } } + Set-Cookie: meridian_session (HttpOnly, SameSite=Lax, Secure in prod, 7-day expiry)
Errors400 VALIDATION_ERROR (malformed body/fields) · 401 INVALID_CREDENTIALSidentical generic message for unknown email, wrong password, inactive user, suspended/archived tenant · 403 FORBIDDEN (foreign Origin) · 429 RATE_LIMITED

POST /api/v1/auth/logout

Auth requiredno (idempotent)
Behaviordestroys the server-side session if resolvable; clears cookie
Success 200{ "success": true, "message": "Logged out.", "data": {} }

GET /api/v1/auth/me

Auth requiredany role
Success 200{ "success": true, "data": { "user": { …SafeUser } } }
Errors401 UNAUTHORIZED (no/expired/invalid session) · 403 ACCOUNT_SUSPENDED · 403 TENANT_SUSPENDED · 403 TENANT_ARCHIVED

Session validity — including user status and (for tenant admins) tenant status — is re-checked fresh on every request.

POST /api/v1/auth/change-password

Auth requiredany role
Body{ "currentPassword": "…", "newPassword": "…" }
Behaviorverifies current → policy (10–128, non-blank) → Argon2id rehash → invalidates every OTHER session, keeps the current one
Success 200{ "success": true, "message": "Password updated. Other sessions have been signed out." }
Errors400 VALIDATION_ERROR · 401 UNAUTHORIZED · 401 INVALID_CREDENTIALS ("Current password is incorrect.")

Unknown auth endpoints — catch-all

Anything else under /api/v1/auth/*404 NOT_FOUND in the standard envelope (never an unstyled HTML 404).


2b · Implemented endpoints — platform tenant management (Phase 4)

All guarded by PLATFORM_ADMIN (anonymous → 401, wrong role → 403). Tenant-context notes and lifecycle invariants: docs/tenant-management.md.

GET /api/v1/platform/tenants

Query params: search (company/slug/admin name/admin email) · status = ALL|ACTIVE|SUSPENDED|ARCHIVED (default ALL = ACTIVE+SUSPENDED) · sort = newest|oldest|most_packages|company_name (default newest) · page ≥ 1 · limit 1–50 (default 20, clamped).

Success 200:

{
  "success": true,
  "data": {
    "items": [
      {
        "id": "…", "companyName": "Swift Logistics", "slug": "swift",
        "status": "ACTIVE",
        "admin": { "id": "…", "name": "Adaeze Okafor", "email": "adaeze@swift.com" },
        "packageCount": 12, "createdAt": "…", "updatedAt": "…"
      }
    ],
    "page": 1, "limit": 20, "total": 37, "totalPages": 2
  }
}

POST /api/v1/platform/tenants — atomic provisioning

Body:

{
  "companyName": "Swift Logistics",
  "slug": "swift",
  "contact": { "phone": "+234 …", "email": "ops@swift.com", "address": "…" },
  "admin": { "name": "Adaeze Okafor", "email": "adaeze@swift.com", "password": "optional-omit-to-generate" }
}

Creates tenant + exactly one TENANT_ADMIN + initialized WebsiteConfig in a MongoDB transaction (sequential-with-rollback fallback on standalone mongod). Success 201: { tenant, admin, temporaryPassword? }temporaryPassword is present only when server-generated and is never retrievable again. Errors: 400 VALIDATION_ERROR · 400 TENANT_SLUG_RESERVED · 409 TENANT_SLUG_ALREADY_EXISTS · 409 EMAIL_ALREADY_EXISTS · 409 TENANT_ADMIN_ALREADY_EXISTS.

GET /api/v1/platform/tenants/:tenantId

Success 200: { tenant: {…identity, contact, timestamps}, admin: {…, status, createdAt} | null, packageCount, websiteConfigured }. Errors: 400 VALIDATION_ERROR (bad id) · 404 TENANT_NOT_FOUND.

PATCH /api/v1/platform/tenants/:tenantId

Body (all optional, strict): { "companyName"?, "slug"?, "contact"? } — status changes go through lifecycle endpoints only. Success 200 with the refreshed details. Errors: as above (TENANT_SLUG_* on conflict).

Lifecycle actions (POST)

/:tenantId/suspend · /:tenantId/archive · /:tenantId/restore

Allowed matrix: suspend: ACTIVE→SUSPENDED · archive: ACTIVE→ARCHIVED · restore: SUSPENDED|ARCHIVED→ACTIVE. Suspension/archival also destroy the tenant's live admin sessions. Success 200 with refreshed details + message. Errors: 400 INVALID_TENANT_STATUS (allowed set named) · 404 TENANT_NOT_FOUND.

GET /api/v1/platform/stats — overview metrics (Phase 8)

Success 200: { tenants: { total, active, suspended, archived }, packages: { total, activeShipments, delivered } }. activeShipments = non-DELIVERED, non-archived packages across the platform.

GET|PATCH /api/v1/platform/tenants/:tenantId/website (Phase 8)

GET → the full editable configuration (same shape the public renderer consumes): branding, navigation, sections (hero/services/about/features/ tracking/contact/footer), sectionOrder, contact, socialLinks, seo. Self-heals: a tenant without a config row gets one initialized.

PATCH body = partial draft, .strict(); deep-merged per section so a partial save never clobbers other sections; publishes immediately. Errors: 400 VALIDATION_ERROR (markup in text, bad hex/URL/icon, unknown key, duplicate/unknown section, oversized list) · 404 TENANT_NOT_FOUND · 401/403 for anyone who is not a PLATFORM_ADMIN.

GET|PATCH /api/v1/platform/tenants/:tenantId/branding (Phase 8)

Focused branding read/save → { branding: { …palette, typography, buttonStyle, borderRadius, theme, logoUrl, faviconUrl, tagline } }. Same validation and authorization rules.

GET /api/v1/platform/tenants/:tenantId/packages (Phase 8)

Server-authorized Platform-Admin READ of one tenant's packages (the "open tenant dashboard" data path — no impersonation, no session swap, no mutations). Paginated { items, page, limit, total, totalPages } where an item is { id, trackingId, packageName, status, receiverName, currentLocationName, createdAt }.

POST /api/v1/platform/tenants/:tenantId/admin/reset-password

Generates a new temporary password, rehashes, invalidates the admin's sessions. Success 200: { admin: {…}, temporaryPassword } (shown once). Errors: 404 TENANT_NOT_FOUND · 404 NOT_FOUND (tenant has no admin).


2c · Implemented endpoints — tenant-admin packages (Phase 5)

All guarded by TENANT_ADMIN with an ACTIVE tenant; every query is scoped by the session's tenantId — cross-tenant access answers 404 PACKAGE_NOT_FOUND (not 403: other tenants' packages are invisible by design). These are ADMIN payloads — never reused for public tracking.

GET /api/v1/admin/packages

Query: search (trackingId/packageName/receiver/sender) · status = ALL|PENDING|PROCESSED|IN_TRANSIT|ARRIVED_AT_FACILITY|DELIVERED · archived=true → archive drawer (default excludes archived) · page · limit ≤ 50. Success 200:

{ "success": true, "data": { "items": [{ "id", "trackingId", "packageName", "status", "archived", "senderName", "receiverName", "currentLocationName", "createdAt", "updatedAt" }], "page": 1, "limit": 20, "total": 42, "totalPages": 3 } }

POST /api/v1/admin/packages

Body: { packageName*, description?, sender* {name,phone,email?,address}, receiver* {…}, specifications? {size?, weight?}, payment? {paymentMethod? FREE TEXT, paymentStatus?, shippingCost?}, delivery? {estimatedDeliveryDate?}, currentLocation? {latitude, longitude, locationName?} }. Server mints the tracking ID (PKG-{TENANT3}-{YYYYMMDD}-{RANDOM6}), writes package + initial PENDING status event (+ first location row if coordinates given) atomically. Success 201: { package: AdminPackageDetails, trackingId }. Errors: 400 VALIDATION_ERROR · 500 TRACKING_ID_GENERATION_FAILED.

GET /api/v1/admin/packages/:packageId

Success 200: full admin projection + statusHistory (ascending) + locationHistory (≤100 newest). Errors: 400 VALIDATION_ERROR (bad id) · 404 PACKAGE_NOT_FOUND.

PATCH /api/v1/admin/packages/:packageId/status

Body { "status": one of the exact five, "note"?: string }. Sequence is not restricted (admin override); change + event are atomic. Errors: 400 VALIDATION_ERROR · 400 INVALID_PACKAGE_STATUS · 400 PACKAGE_ARCHIVED · 404 PACKAGE_NOT_FOUND.

PATCH /api/v1/admin/packages/:packageId/location

Body { "latitude": -90…90, "longitude": -180…180, "locationName"? }. currentLocation update + history append are atomic. Errors: 400 INVALID_LOCATION · 400 PACKAGE_ARCHIVED · 404 PACKAGE_NOT_FOUND.

POST /api/v1/admin/packages/:packageId/archive · /restore

Soft-delete flag only — data and histories retained; restore preserves tracking ID and history. Archived packages reject status/location mutations until restored.

Geocoding (backend-mediated, Phase 7)

Both TENANT_ADMIN-gated, rate-limited 20 requests/IP/minute (in addition to global), normalized GeocodingResult payloads only.

GET /api/v1/admin/geocoding/search?q=Bamenda{ "results": [{ "id", "displayName", "latitude", "longitude" }] }. Errors: 400 VALIDATION_ERROR (q < 2 or > 200 chars) · 429 RATE_LIMITED · 502 UPSTREAM_UNAVAILABLE (provider down).

GET /api/v1/admin/geocoding/reverse?lat=5.96&lng=10.16{ "result": { … } | null }null means "no resolved name", never an error; coordinates remain valid. Errors: as above + coordinate range.

Realtime transport

Socket.IO shares the HTTP server at /socket.io — protocol, rooms, authorization, and payloads live in docs/realtime.md (not a REST surface). Status/location mutations above emit their broadcasts AFTER their database commits.


3 · Implemented endpoints — infrastructure

GET /api/health — liveness (Phase 1)

Always 200 when the process can answer; data.checks.database carries the MongoDB probe (up/down + latency). Error surface: RATE_LIMITED.

GET /api/ready — readiness (Phase 2)

200 + report when MongoDB is reachable, else 503 with { "code": "NOT_READY" }. Route production traffic on this probe.

GET /api/v1/public/track/:trackingIdIMPLEMENTED (Phase 6)

Unauthenticated; hostname resolves the tenant (never a client tenantId). Rate limit: 30 requests/IP/minute plus the global limit.

Success 200 — the STRICT allowlist (docs/public-tracking.md §3):

{
  "success": true,
  "data": {
    "trackingId": "PKG-SWI-20260909-K7Q2X9",
    "packageName": "Documents — Lagos to Abuja",
    "senderName": "Ada",
    "receiverName": "Femi",
    "estimatedDelivery": "2026-10-01T00:00:00.000Z",
    "status": "IN_TRANSIT",
    "lastUpdated": "2026-09-09T12:00:00.000Z",
    "currentLocation": { "latitude": 7.37, "longitude": 3.94, "locationName": "Ibadan waypoint", "updatedAt": "…" },
    "timeline": [
      { "status": "PENDING", "occurredAt": "…" },
      { "status": "IN_TRANSIT", "note": "left Lagos", "occurredAt": "…" }
    ]
  }
}

Never included: phones, emails, addresses, payment metadata, costs, MongoDB ids, tenantId, location history, authentication material.

Errors: 400 VALIDATION_ERROR (malformed id) · 403 TENANT_SUSPENDED / 403 TENANT_ARCHIVED (tracking disabled, non-revealing message) · 404 TENANT_NOT_FOUND (unknown website) · 404 PACKAGE_NOT_FOUND (identical for unknown id, archived package, and foreign-tenant id) · 429 RATE_LIMITED.

Deep links (Phase 9)

GET /track?trackingId={id} on a tenant host — a PAGE deep link, not an API change: the value is validated against the tracker regex and loaded through the same endpoint above. It's how shared tracking links work.

Group stubs — admin / platform (Phase 2/3)

Unmatched paths under /api/v1/admin/* and /api/v1/platform/* still answer 501 NOT_IMPLEMENTED after their role gates (implemented routes live above; unknown sub-paths keep the standard envelope).


4 · Standard response envelopes

Success: { "success": true, "message"?: "…", "data": … }

Failure — everywhere, no exceptions:

{ "success": false, "error": { "code": "PACKAGE_NOT_FOUND", "message": "Package could not be found." } }

Browser mirror: ApiSuccess<T> / ApiFailure in src/types/api.ts; thrown as ApiClientError by src/services/api-client.ts.


5 · Error code registry

CodeHTTPMeaningSince / state
INTERNAL_ERROR500unexpected server failure (logged)Phase 1 · live
NOT_IMPLEMENTED501contract stub for a planned endpointPhase 1 · live
VALIDATION_ERROR400input failed the zod boundaryPhase 2 · live
RATE_LIMITED429sliding-window limit exceededPhase 2 · live
NOT_READY503readiness failed — MongoDB unreachablePhase 2 · live
UPSTREAM_UNAVAILABLE502provider (geocoding) temporarily unavailablePhase 7 · live
FORBIDDEN403disallowed Origin or insufficient rolePhase 2/3 · live
PACKAGE_ARCHIVED400mutation attempted on an archived packagePhase 5 · live
INVALID_PACKAGE_STATUS400not one of the exact five statusesPhase 5 · live
INVALID_LOCATION400coordinates outside valid rangesPhase 5 · live
TRACKING_ID_GENERATION_FAILED500could not allocate a unique tracking IDPhase 5 · live
PACKAGE_NOT_FOUND404package unknown — incl. cross-tenant (invisible by design)Phase 5 · live
UNAUTHORIZED401missing/expired/invalid sessionPhase 3 · live
INVALID_CREDENTIALS401generic login failure (enumeration-safe)Phase 3 · live
ACCOUNT_SUSPENDED403authenticated user was suspendedPhase 3 · live
TENANT_SUSPENDED403tenant suspended (tenant-admin surface)Phase 3 · live
TENANT_ARCHIVED403tenant archived (tenant-admin surface)Phase 3 · live
NOT_FOUND404unknown endpoint / missing resourcePhase 3 · live

6 · Request pipeline (every API route)

withHandler
  1 rate limit        per-IP sliding window (global 120/min; login 10/10min)
  2 origin check      non-GET must come from an allowed origin — 403 FORBIDDEN
  3 controller        readJsonBody → validate(schema, input) → service → ok(data)
  4 error shaping     ApiError → its envelope; unknown → 500 INTERNAL_ERROR
  5 security headers  X-Content-Type-Options, X-Frame-Options, CSP, … — always

withAuth (authenticated routes) composes inside this pipeline and adds:
  cookie → session (hash lookup) → fresh user + tenant status gates → role gate

7 · Conventions

JSON in/out · Content-Type: application/json required for bodies · nouns plural, kebab-case · ids in path params · no stack traces in payloads · no client-supplied tenantId honored (server resolves tenancy from session or host).