Guidance for Splits Network REST API design, implementation, and documentation
This skill provides guidance for designing, implementing, and documenting REST APIs in the Splits Network platform.
Help developers create consistent, well-documented REST APIs that follow Splits Network standards:
{ data, pagination } envelopeUse this skill when:
Every V2 resource follows this exact pattern:
// 1. LIST - Role-scoped collection
GET /api/v2/:resource?search=X&status=Y&sort_by=Z&page=1&limit=25
Response: { data: [...], pagination: { total, page, limit, total_pages } }
// 2. GET BY ID - Single resource
GET /api/v2/:resource/:id?include=related1,related2
Response: { data: {...} }
// 3. CREATE - New resource
POST /api/v2/:resource
Body: { field1: value1, field2: value2, ... }
Response: { data: {...} }
// 4. UPDATE - Single method handles ALL updates
PATCH /api/v2/:resource/:id
Body: { field1: newValue1, status: newStatus, ... }
Response: { data: {...} }
// 5. DELETE - Soft delete
DELETE /api/v2/:resource/:id
Response: { data: { message: 'Deleted successfully' } }
ALL responses MUST use the wrapped envelope:
// Success response
reply.send({ data: <payload> })
// List response with pagination
reply.send({
data: [...],
pagination: { total, page, limit, total_pages }
})
// Error response
reply.code(400).send({
error: { code: "ERROR_CODE", message: "..." }
})
NEVER return unwrapped data: reply.send(payload) is incorrect.
services/<service>/src/v2/
โโโ shared/ # Shared V2 utilities
โ โโโ events.ts # EventPublisher class
โ โโโ helpers.ts # requireUserContext, validation
โ โโโ pagination.ts # PaginationParams, PaginationResponse
โโโ <domain>/ # Domain folder (e.g., jobs, candidates)
โ โโโ types.ts # Domain-specific types
โ โโโ repository.ts # Data access with role-based filtering
โ โโโ service.ts # Business logic, validation, events
โโโ routes.ts # All V2 routes (imports from domains)
import { resolveAccessContext } from "@splits-network/shared-access-context";
export class ResourceRepository {
constructor(private supabase: SupabaseClient) {}
async list(clerkUserId: string, filters: ResourceFilters) {
const context = await resolveAccessContext(clerkUserId, this.supabase);
const query = this.supabase.from("resources").select("*");
// Apply role-based filtering from access context
if (context.role === "recruiter") {
query.eq("user_id", context.userId);
} else if (context.role === "company_admin") {
query.in("company_id", context.accessibleCompanyIds);
}
// Platform admins see everything (no filter)
// Apply search/sorting filters
if (filters.search) {
query.ilike("name", `%${filters.search}%`);
}
if (filters.sort_by) {
query.order(filters.sort_by, {
ascending: filters.sort_order !== "desc",
});
}
return query;
}
}
export class ResourceServiceV2 {
constructor(
private repository: ResourceRepository,
private events: EventPublisher,
) {}
async create(clerkUserId: string, data: ResourceCreate) {
// Validate input
this.validateResourceData(data);
// Create via repository
const resource = await this.repository.create(clerkUserId, data);
// Publish event after successful creation
await this.events.publish("resource.created", {
resourceId: resource.id,
createdBy: clerkUserId,
});
return resource;
}
async update(id: string, clerkUserId: string, updates: ResourceUpdate) {
// Smart validation based on what's being updated
if (updates.status) {
this.validateStatusTransition(updates.status);
}
const updated = await this.repository.update(id, clerkUserId, updates);
// Publish event
await this.events.publish("resource.updated", {
resourceId: id,
changes: Object.keys(updates),
updatedBy: clerkUserId,
});
return updated;
}
}
import { FastifyInstance } from "fastify";
export async function resourceRoutes(
app: FastifyInstance,
service: ResourceServiceV2,
) {
// LIST
app.get("/api/v2/resources", async (request, reply) => {
const clerkUserId = request.headers["x-clerk-user-id"] as string;
const {
search,
status,
sort_by,
sort_order,
page = 1,
limit = 25,
} = request.query as any;
const result = await service.list(clerkUserId, {
search,
status,
sort_by,
sort_order,
page,
limit,
});
return reply.send(result); // Service returns { data, pagination }
});
// GET BY ID
app.get("/api/v2/resources/:id", async (request, reply) => {
const clerkUserId = request.headers["x-clerk-user-id"] as string;
const { id } = request.params as { id: string };
const { include } = request.query as { include?: string };
const resource = await service.getById(id, clerkUserId, include);
return reply.send({ data: resource });
});
// CREATE
app.post("/api/v2/resources", async (request, reply) => {
const clerkUserId = request.headers["x-clerk-user-id"] as string;
const data = request.body as ResourceCreate;
const resource = await service.create(clerkUserId, data);
return reply.code(201).send({ data: resource });
});
// UPDATE
app.patch("/api/v2/resources/:id", async (request, reply) => {
const clerkUserId = request.headers["x-clerk-user-id"] as string;
const { id } = request.params as { id: string };
const updates = request.body as ResourceUpdate;
const resource = await service.update(id, clerkUserId, updates);
return reply.send({ data: resource });
});
// DELETE
app.delete("/api/v2/resources/:id", async (request, reply) => {
const clerkUserId = request.headers["x-clerk-user-id"] as string;
const { id } = request.params as { id: string };
await service.delete(id, clerkUserId);
return reply.send({ data: { message: "Deleted successfully" } });
});
}
Standard query parameters for list endpoints:
interface StandardListParams {
page?: number; // Page number (1-based)
limit?: number; // Items per page (default 25, max 100)
search?: string; // Search term (service-specific fields)
sort_by?: string; // Field to sort by
sort_order?: "asc" | "desc"; // Sort direction
filters?: Record<string, any>; // Domain-specific filters
include?: string; // Comma-separated related resources
}
Examples:
/api/v2/jobs?page=1&limit=25&search=engineer&status=active/api/v2/applications?candidate_id=123&stage=screen&sort_by=created_at&sort_order=desc/api/v2/candidates?include=documents,applicationsSupport include parameter for related data:
// GET /api/v2/applications/:id?include=candidate,job,ai_review
if (includes.includes("candidate")) {
application.candidate = await this.getCandidateData(
application.candidate_id,
);
}
All list endpoints must return pagination metadata:
interface PaginationResponse {
total: number; // Total items across all pages
page: number; // Current page number (1-based)
limit: number; // Items per page
total_pages: number; // Total number of pages
}
// Example response
{
"data": [...],
"pagination": {
"total": 1000,
"page": 1,
"limit": 25,
"total_pages": 40
}
}
Return structured error responses:
// 400 Bad Request - Validation error
reply.code(400).send({
error: {
code: "VALIDATION_ERROR",
message: "Invalid input data",
details: { field: "email", reason: "Invalid email format" },
},
});
// 401 Unauthorized - Missing/invalid auth
reply.code(401).send({
error: {
code: "UNAUTHORIZED",
message: "Authentication required",
},
});
// 403 Forbidden - Insufficient permissions
reply.code(403).send({
error: {
code: "FORBIDDEN",
message: "Insufficient permissions to access this resource",
},
});
// 404 Not Found - Resource doesn't exist
reply.code(404).send({
error: {
code: "NOT_FOUND",
message: "Resource not found",
details: { resourceId: id },
},
});
// 409 Conflict - Duplicate or constraint violation
reply.code(409).send({
error: {
code: "CONFLICT",
message: "Resource already exists",
details: { constraint: "unique_email" },
},
});
// 500 Internal Server Error - Unexpected error
reply.code(500).send({
error: {
code: "INTERNAL_ERROR",
message: "An unexpected error occurred",
},
});
Publish domain events for significant state changes:
// After successful create
await this.events.publish("resource.created", {
resourceId: resource.id,
companyId: resource.company_id,
createdBy: clerkUserId,
});
// After successful update
await this.events.publish("resource.updated", {
resourceId: id,
changes: Object.keys(updates),
updatedBy: clerkUserId,
});
// After successful delete
await this.events.publish("resource.deleted", {
resourceId: id,
deletedBy: clerkUserId,
});
// Domain-specific events
await this.events.publish("application.stage_changed", {
applicationId: id,
oldStage: oldStage,
newStage: newStage,
changedBy: clerkUserId,
});
Document each endpoint with:
### List Jobs
Returns a paginated list of jobs based on role-based access control.
**Endpoint**: `GET /api/v2/jobs`
**Authentication**: Required (Clerk JWT)
**Access Control**:
- **Recruiters**: See assigned jobs only
- **Company Users**: See jobs from their organization
- **Platform Admins**: See all jobs
**Query Parameters**:
- `page` (number, optional): Page number (default: 1)
- `limit` (number, optional): Items per page (default: 25, max: 100)
- `search` (string, optional): Search in job title
- `status` (string, optional): Filter by status (active, paused, closed)
- `company_id` (uuid, optional): Filter by company
- `sort_by` (string, optional): Sort field (created_at, title)
- `sort_order` (string, optional): Sort direction (asc, desc)
**Response**: `200 OK`
```json
{
"data": [
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"title": "Senior Software Engineer",
"company_id": "123e4567-e89b-12d3-a456-426614174001",
"status": "active",
"location": "San Francisco, CA",
"created_at": "2026-01-13T10:00:00Z"
}
],
"pagination": {
"total": 100,
"page": 1,
"limit": 25,
"total_pages": 4
}
}
```
Errors:
401 Unauthorized: Missing or invalid authentication403 Forbidden: Insufficient permissions500 Internal Server Error: Server errorExample Request:
curl -X GET "https://api.splits.network/api/v2/jobs?page=1&limit=25&status=active" \
-H "Authorization: Bearer <clerk-jwt>"
## Common Patterns
### Current User Access Pattern
For user-specific singleton resources, use the `/me` alias pattern:
```typescript
// โ
RECOMMENDED - Use /me alias on existing GET by ID endpoint
GET /api/v2/candidates/me
// Resolves to user's actual ID, returns { data: {...} }
// Implementation in route handler
app.get('/api/v2/candidates/:id', async (request, reply) => {
const clerkUserId = request.headers['x-clerk-user-id'] as string;
let { id } = request.params;
// Resolve "me" to actual user ID
if (id === 'me') {
const context = await resolveAccessContext(clerkUserId, supabase);
id = context.userId; // Actual UUID
}
// Standard getById logic - no special handling needed
const candidate = await service.getById(id, clerkUserId);
return reply.send({ data: candidate });
});
Benefits:
/me means{ data: {...} } (singleton, not array)One update method handles ALL updates with smart validation:
// โ WRONG - Multiple update endpoints
PATCH /api/v2/jobs/:id/status
PATCH /api/v2/jobs/:id/title
PATCH /api/v2/jobs/:id/close
// โ
CORRECT - Single update endpoint
PATCH /api/v2/jobs/:id
Body: { status: "closed" } or { title: "New Title" } or any field
Use include parameters for related data, NOT child endpoints:
// โ WRONG - Child endpoints
GET /api/v2/applications/:id/documents
GET /api/v2/applications/:id/ai-review
// โ
CORRECT - Include parameters
GET /api/v2/applications/:id?include=documents,ai_review
GET /api/v2/documents?application_id=:id
GET /api/v2/ai-reviews?application_id=:id
Services share the same database (all tables in public schema) and can enrich data with JOINs:
// Enrich applications with candidate and job data
const enrichedApplications = await this.supabase.from("applications").select(`
*,
candidate:candidates(*),
job:jobs(*),
recruiter:recruiters(*)
`);
// Note: All domain tables (applications, candidates, jobs, recruiters, etc.)
// are in the public schema. No cross-schema queries needed.
docs/guidance/api-response-format.mddocs/guidance/pagination.mddocs/migration/v2/V2-ARCHITECTURE-IMPLEMENTATION-GUIDE.mddocs/guidance/service-architecture-pattern.mdpackages/shared-access-context/README.mdV2 Services:
services/identity-service/src/v2/services/ats-service/src/v2/services/network-service/src/v2/services/billing-service/src/v2/services/notification-service/src/v2/API Gateway Routes:
services/api-gateway/src/routes/v2/