API Overview
Leadmetrics exposes four separate API surfaces — one per client — served from a single Fastify instance on the api service. Each surface has its own route prefix, auth middleware, and permission set. Shared middleware (authentication, tenant resolution, error handling, rate limiting) lives in a common/ module consumed by all four routers.
Related: Infrastructure — service ports | Multi-tenancy — tenant isolation | Governance — approval + permission model
Architecture: Gateway Layer
All requests pass through a gateway layer before reaching any surface router. The gateway owns every cross-cutting concern so surface routers stay focused on business logic.
Client → Gateway (auth · tenant · rate-limit · throttle · logging · audit) → Surface RouterSee Gateway for the full spec — request lifecycle, hook order, audit log schema, and the future Option B upgrade path to a separate gateway service.
API Surfaces
| Surface | Fastify Prefix | Audience |
|---|---|---|
| Auth | /auth/v1 | All apps — login, refresh, profile, registration, plans |
| Tenant | /tenant/v1 | Dashboard + DM Portal — agents, billing, context, strategy, notifications, docs, knowledge |
| Media | /media/v1 | Dashboard + DM Portal — image search and asset management |
| Admin | /admin/v1 | Manage app — tenants, users, agents, billing, skills, templates, audit logs |
| Payment Gateway | /pg/v1 | Payment provider webhooks (Razorpay) |
The Next.js apps proxy requests through their own
/api/routes to the Fastify server. The Fastify prefixes above are the actual backend paths.
Base URLs
| Environment | Base URL |
|---|---|
| Production (SaaS) | https://api.leadmetrics.io |
| On-prem | https://api.<customer-domain> |
| Local development | http://localhost:3001 |
Full endpoint URL = base + prefix + path, e.g.:
GET https://api.leadmetrics.io/dashboard/v1/campaignsAuthentication
All endpoints (except /auth/v1/login) require a Bearer token in the Authorization header:
Authorization: Bearer <JWT>JWTs are issued by POST /auth/v1/login and refreshed via POST /auth/v1/refresh. The JWT payload carries:
interface LeadmetricsJWT {
sub: string; // user.ref_id
tenantId: string; // tenant.ref_id (absent for super_admin cross-tenant tokens)
role: 'admin' | 'member' | 'reviewer' | 'super_admin';
appAccess: ('dashboard' | 'dm-portal' | 'manage')[];
iat: number;
exp: number; // 15 min for access tokens, 7 days for refresh tokens
}Middleware enforcement per surface:
| Surface | Required role | Required appAccess |
|---|---|---|
| Dashboard API | admin or member | dashboard |
| DM API | reviewer or admin | dm-portal |
| Admin API | super_admin | manage |
| Mobile API | admin or member | dashboard |
Access to the wrong surface returns 403 Forbidden.
Tenant Context
For Dashboard and Mobile APIs, the tenant is resolved from the JWT tenantId. Every query, queue operation, and data write is automatically scoped to that tenant — callers never pass tenantId explicitly.
For the DM API, the reviewer’s JWT has no fixed tenantId. Instead, endpoints accept an optional tenantId query parameter to filter to a specific tenant; omitting it returns data across all assigned tenants.
For the Admin API, there is no tenant scope by default. Tenant-specific sub-resources (e.g. GET /admin/v1/tenants/:tenantId/agents) accept a tenantId path segment.
Identifiers
All resource identifiers exposed in the API are ULIDs stored in the ref_id column (PostgreSQL) or refId field (MongoDB). Internal UUIDs are never exposed. ULIDs are:
- URL-safe (no special characters)
- Lexicographically sortable by creation time
- 26 characters, e.g.
01ARZ3NDEKTSV4RRFFQ69G5FAV
Path parameters use the ULID directly:
GET /dashboard/v1/campaigns/01ARZ3NDEKTSV4RRFFQ69G5FAVPagination
List endpoints that may return large result sets use cursor-based pagination:
// Query parameters (all optional)
interface PaginationQuery {
limit?: number; // default 25, max 100
cursor?: string; // ULID of the last item from the previous page
order?: 'asc' | 'desc'; // default 'desc' (newest first)
}
// Response envelope
interface PaginatedResponse<T> {
data: T[];
pagination: {
hasMore: boolean;
nextCursor: string | null; // pass as cursor on next request
total?: number; // included when count is cheap; omitted otherwise
};
}Filtering & Sorting
Endpoints that support filtering document their accepted filter.* query parameters. All filter parameters are optional — omitting them returns all records visible to the caller.
GET /dashboard/v1/activities?filter.status=in_progress&filter.agentRole=copywriter
GET /dm/v1/approvals?filter.riskLevel=high&filter.tenantId=01ARZ...Sort order is controlled by order=asc|desc. The sort key is always the primary timestamp for that resource (e.g. created_on for campaigns, updated_on for activities).
Error Format
All errors return a consistent JSON body:
interface ApiError {
error: {
code: string; // machine-readable, e.g. 'NOT_FOUND', 'VALIDATION_ERROR'
message: string; // human-readable description
field?: string; // for VALIDATION_ERROR — which field failed
details?: unknown; // additional structured context (optional)
};
}Standard HTTP status codes:
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Request body or query param fails validation |
| 401 | UNAUTHORIZED | Missing or invalid JWT |
| 403 | FORBIDDEN | Valid JWT but insufficient role/permission |
| 404 | NOT_FOUND | Resource does not exist or is not visible to caller |
| 409 | CONFLICT | Operation conflicts with current state (e.g. approval already resolved) |
| 422 | UNPROCESSABLE | Request is valid but cannot be acted on (e.g. retry a running activity) |
| 429 | RATE_LIMITED | Too many requests |
| 500 | INTERNAL_ERROR | Unexpected server error |
Rate Limits
Rate limits are enforced per (tenantId, userId) pair using a sliding window in Redis.
| Surface | Limit |
|---|---|
| Dashboard API | 300 requests / minute |
| DM API | 600 requests / minute |
| Admin API | 120 requests / minute |
| Mobile API | 200 requests / minute |
| Agent callback | 60 requests / minute per runId |
Rate limit headers are included on every response:
X-RateLimit-Limit: 300
X-RateLimit-Remaining: 247
X-RateLimit-Reset: 1704067261On 429, Retry-After is also included.
Versioning
The API is versioned in the URL prefix (/v1/). Breaking changes increment the version. Non-breaking additions (new fields, new endpoints) are made in place without a version bump.
A version is supported for a minimum of 12 months after the next version is released. Deprecation is communicated via Deprecation and Sunset response headers.
Server-Sent Events (SSE)
Real-time streaming endpoints return Content-Type: text/event-stream and follow the SSE protocol. Each event has a type field:
event: text_delta
data: {"delta":"Here is the","activityId":"01ARZ..."}
event: activity_completed
data: {"activityId":"01ARZ...","status":"done","outputRef":"mongo:..."}
event: approval_created
data: {"approvalId":"01ARZ...","type":"content_review","riskLevel":"high"}SSE connections are served from the Fastify API service (not Next.js API routes), which supports long-lived connections. The client uses EventSource (web) or the eventsource npm package (React Native).
Package Structure
apps/api/src/
├── app.ts # Fastify app setup — plugins, routers, error handler
├── index.ts # Bootstrap — starts server, Redis plugin, worker
├── lib/
│ ├── auth.ts # requireTenantUser, apiError, bearerToken helpers
│ ├── jwt.ts # JWT sign/verify (returns null, never throws)
│ └── logger.ts # Shared pino instance (LOG_LEVEL env)
├── services/ # Pure business logic, no Fastify deps
│ ├── auth.service.ts # Registration helpers, invoice number generation
│ ├── billing.service.ts # Invoice amounts, line items, billing dates
│ ├── blog.service.ts # Blog post approval/rejection state machine
│ ├── social.service.ts # Social post approval/rejection state machine
│ ├── campaign.service.ts # Campaign import + create helpers
│ ├── campaigns.service.ts # Google Ads token + service helpers
│ ├── channels.service.ts # Channel metric aggregators, date utils
│ ├── channel-connect.service.ts # OAuth helpers, callback URL builders
│ ├── tenants.service.ts # Tenant invoice + social post RAG helpers
│ ├── strategy.service.ts # Strategy pipeline helpers
│ ├── landing-page.service.ts # Landing page helpers
│ ├── lead.service.ts # Lead helpers
│ ├── connected-channel.service.ts
│ ├── baseline-sync.service.ts
│ └── zoho-books-sync.ts # Zoho Books API integration
├── routers/
│ ├── auth.ts # /auth/v1 — login, refresh, logout, me, plans, register
│ ├── admin.ts # /admin/v1 — tenants, users, agents, billing, skills, templates, audit
│ ├── tenant.ts # /tenant/v1 — agents, billing, context, strategy, notifications, chat
│ ├── media.ts # /media/v1 — image search (Pixabay/Unsplash), pick, assets
│ ├── docs.ts # /tenant/v1/docs — document upload, list, delete (RAG)
│ ├── knowledge.ts # /tenant/v1/knowledge — knowledge base queries
│ └── rag-config.ts # /admin/v1/rag-config — Qdrant / embedding config
├── schemas/ # OpenAPI schema objects for Swagger docs
│ ├── shared.ts # Bearer auth, error responses, common fragments
│ ├── tenant.schema.ts # Schemas for /tenant/v1 routes
│ ├── docs.schema.ts # Schemas for /tenant/v1/docs routes
│ └── media.schema.ts # Schemas for /media/v1 routes
├── socket/
│ └── index.ts # Socket.IO — notifications, presence, agent events (4 Redis connections)
├── worker.ts # BullMQ worker — processes agent jobs
└── __tests__/
├── setup.ts # Test DB config, env var stubs
├── helpers.ts # cleanDb(), createTenant(), createUser()
├── jwt.test.ts # Unit tests for lib/jwt.ts
├── lib-auth.test.ts # Unit tests for lib/auth.ts
├── media-router.test.ts
└── integration/
├── admin-users.test.ts
├── auth-profile.test.ts # GET/PATCH /auth/v1/me, password change, plans, refresh, logout
├── docs.test.ts
├── knowledge.test.ts
├── notifications.test.ts
├── rag-config.test.ts
├── register.test.ts
├── security.test.ts
├── tenant-notification-prefs.test.ts
└── tenant-pipeline.test.ts