
api-specifications
by splits-network
SKILL.md
name: api-specifications description: Guidance for Splits Network REST API design, implementation, and documentation alwaysApply: false applyTo:
- "services//routes/"
- "services//src/v2/"
- "apps//src/app/api/"
- "docs/guidance/api-*.md"
- "docs/guidance/-endpoints-.md"
API Specifications Skill
This skill provides guidance for designing, implementing, and documenting REST APIs in the Splits Network platform.
Purpose
Help developers create consistent, well-documented REST APIs that follow Splits Network standards:
- V2 Architecture: Standardized 5-route CRUD pattern
- Response Format: Consistent
{ data, pagination }envelope - Access Control: Role-based filtering via shared access context
- Event Publishing: Domain events for state changes
- Documentation: Clear endpoint specs and examples
When to Use This Skill
Use this skill when:
- Creating new REST API endpoints
- Migrating V1 endpoints to V2 architecture
- Documenting API contracts
- Implementing pagination, filtering, or search
- Adding validation or error handling
- Publishing domain events
V2 API Architecture Standards
1. Standardized 5-Route Pattern
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' } }
2. Response Format Standard
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.
3. Domain-Based Folder Structure
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)
4. Repository Pattern with Access Context
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;
}
}
5. Service Layer with Events
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;
}
}
6. Route Implementation
import { FastifyInstance } from 'fastify';
export async function resourceRoutes(app: FastifyInstance, service: ResourceServiceV2) {
// LIST
app.get('/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('/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('/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('/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('/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' } });
});
}
Query Parameters
List Endpoints
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,applications
Get By ID Endpoints
Support 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);
}
Pagination
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
}
}
Error Handling
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'
}
});
Event Publishing
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,
});
API Documentation
Document each endpoint with:
- Purpose: What the endpoint does
- Path: Full path with parameters
- Method: HTTP method (GET, POST, PATCH, DELETE)
- Auth: Required authentication (Clerk JWT)
- Access: Who can access (roles, scoping rules)
- Query Params: Optional parameters
- Request Body: Expected payload structure
- Response: Success response format
- Errors: Possible error codes and meanings
- Example: Full request/response example
Example Documentation
### 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 error
Example 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('/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:
- Intuitive: Clear what
/memeans - Performant: Direct ID lookup (not filtered list query)
- Correct Shape: Returns
{ data: {...} }(singleton, not array) - No New Endpoint: Just an alias within existing GET by ID route
- Still Secure: Access context validates user can access resolved ID
Single Update Method
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
Include Parameters vs Child Endpoints
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
Data Enrichment with JOINs
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.
References
- API Response Format:
docs/guidance/api-response-format.md - Pagination Standard:
docs/guidance/pagination.md - V2 Architecture Guide:
docs/migration/v2/V2-ARCHITECTURE-IMPLEMENTATION-GUIDE.md - Service Architecture:
docs/guidance/service-architecture-pattern.md - Access Context:
packages/shared-access-context/README.md
Examples from Production
V2 Services:
- Identity Service:
services/identity-service/src/v2/ - ATS Service:
services/ats-service/src/v2/ - Network Service:
services/network-service/src/v2/ - Billing Service:
services/billing-service/src/v2/ - Notification Service:
services/notification-service/src/v2/
API Gateway Routes:
- Gateway V2 Proxy:
services/api-gateway/src/routes/v2/
Score
Total Score
Based on repository quality metrics
SKILL.mdファイルが含まれている
ライセンスが設定されている
100文字以上の説明がある
GitHub Stars 100以上
3ヶ月以内に更新がある
10回以上フォークされている
オープンIssueが50未満
プログラミング言語が設定されている
1つ以上のタグが設定されている
Reviews
Reviews coming soon