Build or update the BlueBubbles external channel plugin for Moltbot (extension package, REST...
npx skills add jgarrison929/openclaw-skills --skill "api-developer"
Install specific skill from multi-skill repository
# Description
Use when designing REST APIs, implementing API versioning, error handling, OpenAPI/Swagger specs, rate limiting, authentication patterns, or building any HTTP API backend.
# SKILL.md
name: api-developer
version: 1.0.0
description: Use when designing REST APIs, implementing API versioning, error handling, OpenAPI/Swagger specs, rate limiting, authentication patterns, or building any HTTP API backend.
triggers:
- REST API
- API design
- API versioning
- OpenAPI
- Swagger
- rate limiting
- API authentication
- JWT
- OAuth
- API documentation
- error handling API
- pagination
- HATEOAS
- API gateway
- endpoint design
- request validation
- response format
role: specialist
scope: implementation
output-format: code
API Developer
Senior API engineer specializing in RESTful API design, versioning, authentication, error handling, documentation, and production-grade API patterns.
Role Definition
You are a senior API engineer building production-grade HTTP APIs. You follow REST principles pragmatically, design for consistency and developer experience, and implement proper security, validation, rate limiting, and documentation.
Core Principles
- Consistency above all β uniform naming, response formats, error shapes
- Resources, not actions β
POST /ordersnotPOST /createOrder - Status codes mean something β use them correctly, every time
- Validate everything β never trust client input
- Document as you build β OpenAPI spec is the contract
- Version from day one β breaking changes are inevitable
URL Design
Resource Naming Conventions
# β
Good: Plural nouns, lowercase, hyphens for multi-word
GET /api/v1/users
GET /api/v1/users/{id}
POST /api/v1/users
PUT /api/v1/users/{id}
PATCH /api/v1/users/{id}
DELETE /api/v1/users/{id}
# β
Good: Nested resources for relationships
GET /api/v1/users/{userId}/orders
GET /api/v1/users/{userId}/orders/{orderId}
POST /api/v1/users/{userId}/orders
# β
Good: Filtering, sorting, pagination via query params
GET /api/v1/products?category=electronics&sort=-price&page=2&limit=20
# β
Good: Actions as sub-resources when REST doesn't fit
POST /api/v1/orders/{id}/cancel
POST /api/v1/users/{id}/reset-password
# β Bad: Verbs in URLs, camelCase, deeply nested
GET /api/v1/getUser/123
POST /api/v1/createNewOrder
GET /api/v1/users/123/orders/456/items/789/reviews
Standardized Response Format
// types/api.ts
// Success response
interface ApiResponse<T> {
status: "success";
data: T;
meta?: {
pagination?: PaginationMeta;
requestId: string;
timestamp: string;
};
}
// Error response
interface ApiError {
status: "error";
error: {
code: string; // Machine-readable: "VALIDATION_ERROR"
message: string; // Human-readable: "Invalid request body"
details?: ErrorDetail[];
requestId: string;
timestamp: string;
};
}
interface ErrorDetail {
field: string;
message: string;
code: string;
}
interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
}
Express.js Response Helpers
// utils/response.js
class ApiResponse {
static success(res, data, meta = {}, statusCode = 200) {
return res.status(statusCode).json({
status: "success",
data,
meta: {
...meta,
requestId: res.locals.requestId,
timestamp: new Date().toISOString(),
},
});
}
static created(res, data) {
return this.success(res, data, {}, 201);
}
static noContent(res) {
return res.status(204).send();
}
static paginated(res, data, pagination) {
return this.success(res, data, { pagination });
}
static error(res, statusCode, code, message, details = null) {
const response = {
status: "error",
error: {
code,
message,
requestId: res.locals.requestId,
timestamp: new Date().toISOString(),
},
};
if (details) response.error.details = details;
return res.status(statusCode).json(response);
}
}
// Standard error codes
const ErrorCodes = {
VALIDATION_ERROR: { status: 400, code: "VALIDATION_ERROR" },
UNAUTHORIZED: { status: 401, code: "UNAUTHORIZED" },
FORBIDDEN: { status: 403, code: "FORBIDDEN" },
NOT_FOUND: { status: 404, code: "NOT_FOUND" },
CONFLICT: { status: 409, code: "CONFLICT" },
RATE_LIMITED: { status: 429, code: "RATE_LIMITED" },
INTERNAL_ERROR: { status: 500, code: "INTERNAL_ERROR" },
};
HTTP Status Codes Reference
2xx Success:
200 OK β GET, PUT, PATCH success
201 Created β POST success (include Location header)
204 No Content β DELETE success
3xx Redirect:
301 Moved β Permanent URL change
304 Not Modified β Conditional GET, use cached version
4xx Client Error:
400 Bad Request β Malformed request, validation failure
401 Unauthorized β Missing or invalid authentication
403 Forbidden β Authenticated but insufficient permissions
404 Not Found β Resource doesn't exist
405 Method Not Allowed β HTTP method not supported for this URL
409 Conflict β Resource state conflict (duplicate, concurrent edit)
422 Unprocessable β Syntactically valid but semantically wrong
429 Too Many Requests β Rate limit exceeded (include Retry-After header)
5xx Server Error:
500 Internal Error β Unexpected server failure
502 Bad Gateway β Upstream service failure
503 Service Unavailable β Temporary overload (include Retry-After)
504 Gateway Timeout β Upstream timeout
Authentication Patterns
JWT with Refresh Tokens
// middleware/auth.js
const jwt = require("jsonwebtoken");
const ACCESS_TOKEN_TTL = "15m";
const REFRESH_TOKEN_TTL = "7d";
function generateTokens(user) {
const accessToken = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: ACCESS_TOKEN_TTL, issuer: "api.example.com" }
);
const refreshToken = jwt.sign(
{ userId: user.id, tokenVersion: user.tokenVersion },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: REFRESH_TOKEN_TTL, issuer: "api.example.com" }
);
return { accessToken, refreshToken };
}
function authenticate(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith("Bearer ")) {
return ApiResponse.error(res, 401, "UNAUTHORIZED", "Missing access token");
}
try {
const token = header.slice(7);
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: "api.example.com",
});
req.user = decoded;
next();
} catch (err) {
if (err.name === "TokenExpiredError") {
return ApiResponse.error(res, 401, "TOKEN_EXPIRED", "Access token expired");
}
return ApiResponse.error(res, 401, "UNAUTHORIZED", "Invalid access token");
}
}
function authorize(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return ApiResponse.error(res, 403, "FORBIDDEN",
`Requires role: ${roles.join(" or ")}`);
}
next();
};
}
// Routes
router.post("/auth/login", async (req, res) => {
const { email, password } = req.body;
const user = await userService.authenticate(email, password);
if (!user) {
return ApiResponse.error(res, 401, "INVALID_CREDENTIALS", "Invalid email or password");
}
const tokens = generateTokens(user);
ApiResponse.success(res, { user: sanitize(user), ...tokens });
});
router.post("/auth/refresh", async (req, res) => {
const { refreshToken } = req.body;
try {
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
const user = await userService.findById(decoded.userId);
if (!user || user.tokenVersion !== decoded.tokenVersion) {
return ApiResponse.error(res, 401, "INVALID_TOKEN", "Refresh token revoked");
}
const tokens = generateTokens(user);
ApiResponse.success(res, tokens);
} catch {
ApiResponse.error(res, 401, "INVALID_TOKEN", "Invalid refresh token");
}
});
API Key Authentication
// middleware/apiKey.js
function apiKeyAuth(req, res, next) {
const apiKey = req.headers["x-api-key"] || req.query.api_key;
if (!apiKey) {
return ApiResponse.error(res, 401, "MISSING_API_KEY", "API key required");
}
// Hash the key for constant-time comparison
const hashedKey = crypto.createHash("sha256").update(apiKey).digest("hex");
const client = await db.apiKeys.findOne({ keyHash: hashedKey, active: true });
if (!client) {
return ApiResponse.error(res, 401, "INVALID_API_KEY", "Invalid API key");
}
req.client = { id: client.clientId, name: client.name, scopes: client.scopes };
next();
}
Rate Limiting
// middleware/rateLimiter.js
const Redis = require("ioredis");
const redis = new Redis(process.env.REDIS_URL);
function rateLimiter({ windowMs = 60000, max = 100, keyFn = null } = {}) {
return async (req, res, next) => {
const key = keyFn
? keyFn(req)
: `rl:${req.user?.id || req.ip}:${req.route?.path || req.path}`;
const windowSec = Math.ceil(windowMs / 1000);
const multi = redis.multi();
multi.incr(key);
multi.expire(key, windowSec);
const [count] = await multi.exec();
const current = count[1];
// Set rate limit headers (RFC 6585 / draft-ietf-httpapi-ratelimit-headers)
res.set({
"X-RateLimit-Limit": max,
"X-RateLimit-Remaining": Math.max(0, max - current),
"X-RateLimit-Reset": new Date(Date.now() + windowMs).toISOString(),
});
if (current > max) {
res.set("Retry-After", windowSec);
return ApiResponse.error(res, 429, "RATE_LIMITED",
`Rate limit exceeded. Try again in ${windowSec}s`);
}
next();
};
}
// Different limits for different endpoints
router.use("/api/v1/auth", rateLimiter({ windowMs: 900000, max: 10 })); // 10/15min
router.use("/api/v1", rateLimiter({ windowMs: 60000, max: 100 })); // 100/min
router.use("/api/v1/search", rateLimiter({ windowMs: 60000, max: 30 })); // 30/min
Request Validation
// middleware/validate.js
const Joi = require("joi");
function validate(schema) {
return (req, res, next) => {
const targets = { body: req.body, query: req.query, params: req.params };
const errors = [];
for (const [target, rules] of Object.entries(schema)) {
if (!rules) continue;
const { error, value } = rules.validate(targets[target], {
abortEarly: false,
stripUnknown: true,
});
if (error) {
errors.push(
...error.details.map((d) => ({
field: `${target}.${d.path.join(".")}`,
message: d.message,
code: "INVALID_FIELD",
}))
);
} else {
req[target] = value; // Replace with sanitized values
}
}
if (errors.length > 0) {
return ApiResponse.error(res, 400, "VALIDATION_ERROR",
"Request validation failed", errors);
}
next();
};
}
// Schema definitions
const userSchemas = {
create: {
body: Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8)
.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/).required()
.messages({ "string.pattern.base": "Must include uppercase, lowercase, and number" }),
name: Joi.string().trim().min(1).max(100).required(),
}),
},
list: {
query: Joi.object({
page: Joi.number().integer().min(1).default(1),
limit: Joi.number().integer().min(1).max(100).default(20),
sort: Joi.string().valid("name", "email", "createdAt", "-name", "-email", "-createdAt")
.default("createdAt"),
search: Joi.string().trim().min(1).max(200),
status: Joi.string().valid("active", "inactive", "suspended"),
}),
},
getById: {
params: Joi.object({
id: Joi.string().uuid().required(),
}),
},
};
// Usage
router.post("/users", validate(userSchemas.create), userController.create);
router.get("/users", validate(userSchemas.list), userController.list);
router.get("/users/:id", validate(userSchemas.getById), userController.getById);
Pagination Patterns
// utils/pagination.js
// Offset-based pagination (simple, good for most cases)
async function paginateOffset(model, query, { page = 1, limit = 20 }) {
const offset = (page - 1) * limit;
const [data, total] = await Promise.all([
model.find(query).skip(offset).limit(limit),
model.countDocuments(query),
]);
return {
data,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1,
},
};
}
// Cursor-based pagination (better for large datasets, real-time feeds)
async function paginateCursor(model, query, { cursor, limit = 20, sortField = "_id" }) {
const filter = cursor
? { ...query, [sortField]: { $gt: cursor } }
: query;
const data = await model.find(filter).sort({ [sortField]: 1 }).limit(limit + 1);
const hasNext = data.length > limit;
if (hasNext) data.pop();
return {
data,
pagination: {
limit,
hasNext,
nextCursor: hasNext ? data[data.length - 1][sortField] : null,
},
};
}
OpenAPI Specification
# openapi.yaml
openapi: 3.1.0
info:
title: Example API
version: 1.0.0
description: Production REST API
contact:
email: [email protected]
license:
name: MIT
servers:
- url: https://api.example.com/v1
description: Production
- url: https://staging-api.example.com/v1
description: Staging
paths:
/users:
get:
operationId: listUsers
summary: List users
tags: [Users]
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/PageParam'
- $ref: '#/components/parameters/LimitParam'
- name: search
in: query
schema:
type: string
maxLength: 200
responses:
'200':
description: Users list
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/SuccessResponse'
- type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
'401':
$ref: '#/components/responses/Unauthorized'
post:
operationId: createUser
summary: Create a user
tags: [Users]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
responses:
'201':
description: User created
headers:
Location:
schema:
type: string
example: /api/v1/users/550e8400-e29b-41d4-a716-446655440000
'400':
$ref: '#/components/responses/ValidationError'
'409':
$ref: '#/components/responses/Conflict'
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
apiKey:
type: apiKey
in: header
name: X-API-Key
parameters:
PageParam:
name: page
in: query
schema:
type: integer
minimum: 1
default: 1
LimitParam:
name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
schemas:
User:
type: object
properties:
id:
type: string
format: uuid
email:
type: string
format: email
name:
type: string
role:
type: string
enum: [user, admin, manager]
status:
type: string
enum: [active, inactive, suspended]
createdAt:
type: string
format: date-time
CreateUserRequest:
type: object
required: [email, password, name]
properties:
email:
type: string
format: email
password:
type: string
minLength: 8
name:
type: string
minLength: 1
maxLength: 100
SuccessResponse:
type: object
properties:
status:
type: string
enum: [success]
meta:
type: object
properties:
requestId:
type: string
timestamp:
type: string
format: date-time
ErrorResponse:
type: object
properties:
status:
type: string
enum: [error]
error:
type: object
properties:
code:
type: string
message:
type: string
details:
type: array
items:
type: object
properties:
field:
type: string
message:
type: string
responses:
Unauthorized:
description: Authentication required
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
ValidationError:
description: Request validation failed
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
Conflict:
description: Resource conflict
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
API Versioning Strategy
// middleware/versioning.js
// URL path versioning (recommended β explicit, cacheable)
app.use("/api/v1", v1Router);
app.use("/api/v2", v2Router);
// Header versioning (useful for non-breaking changes)
function apiVersion(req, res, next) {
const version = req.headers["api-version"] || "1";
req.apiVersion = parseInt(version, 10);
res.set("API-Version", version);
next();
}
// Deprecation headers
function deprecated(sunset) {
return (req, res, next) => {
res.set("Deprecation", "true");
res.set("Sunset", sunset); // RFC 8594
res.set("Link", '</api/v2/users>; rel="successor-version"');
next();
};
}
// Apply deprecation to v1
v1Router.use("/users", deprecated("2026-01-01T00:00:00Z"));
Global Error Handler
// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
// Log internal details
const requestId = res.locals.requestId;
console.error(`[${requestId}] ${err.stack}`);
// Known operational errors
if (err.isOperational) {
return ApiResponse.error(res, err.statusCode, err.code, err.message, err.details);
}
// Mongoose validation errors
if (err.name === "ValidationError") {
const details = Object.values(err.errors).map((e) => ({
field: e.path,
message: e.message,
code: "INVALID_FIELD",
}));
return ApiResponse.error(res, 400, "VALIDATION_ERROR", "Validation failed", details);
}
// Duplicate key (MongoDB)
if (err.code === 11000) {
const field = Object.keys(err.keyPattern)[0];
return ApiResponse.error(res, 409, "CONFLICT",
`A resource with this ${field} already exists`);
}
// JWT errors
if (err.name === "JsonWebTokenError") {
return ApiResponse.error(res, 401, "UNAUTHORIZED", "Invalid token");
}
// Default: don't leak internals
ApiResponse.error(res, 500, "INTERNAL_ERROR",
process.env.NODE_ENV === "production"
? "An unexpected error occurred"
: err.message
);
}
// 404 handler β must be last route
function notFoundHandler(req, res) {
ApiResponse.error(res, 404, "NOT_FOUND",
`Route ${req.method} ${req.originalUrl} not found`);
}
API Testing
// tests/api/users.test.js
const request = require("supertest");
const app = require("../../app");
describe("POST /api/v1/users", () => {
it("creates a user with valid data", async () => {
const res = await request(app)
.post("/api/v1/users")
.set("Authorization", `Bearer ${adminToken}`)
.send({ email: "[email protected]", password: "Secure123!", name: "Test User" })
.expect(201);
expect(res.body.status).toBe("success");
expect(res.body.data).toHaveProperty("id");
expect(res.body.data.email).toBe("[email protected]");
expect(res.body.data).not.toHaveProperty("password");
expect(res.headers.location).toMatch(/\/api\/v1\/users\/.+/);
});
it("rejects invalid email", async () => {
const res = await request(app)
.post("/api/v1/users")
.set("Authorization", `Bearer ${adminToken}`)
.send({ email: "not-an-email", password: "Secure123!", name: "Test" })
.expect(400);
expect(res.body.status).toBe("error");
expect(res.body.error.code).toBe("VALIDATION_ERROR");
expect(res.body.error.details[0].field).toBe("body.email");
});
it("returns 409 on duplicate email", async () => {
await request(app)
.post("/api/v1/users")
.set("Authorization", `Bearer ${adminToken}`)
.send({ email: existingUser.email, password: "Secure123!", name: "Test" })
.expect(409);
});
it("returns 401 without auth", async () => {
await request(app)
.post("/api/v1/users")
.send({ email: "[email protected]", password: "Secure123!", name: "Test" })
.expect(401);
});
it("returns 429 when rate limited", async () => {
const promises = Array(15).fill().map(() =>
request(app)
.post("/api/v1/auth/login")
.send({ email: "[email protected]", password: "wrong" })
);
const responses = await Promise.all(promises);
const rateLimited = responses.filter((r) => r.status === 429);
expect(rateLimited.length).toBeGreaterThan(0);
expect(rateLimited[0].headers["retry-after"]).toBeDefined();
});
});
Anti-Patterns to Avoid
- β Verbs in URLs β use
POST /ordersnotPOST /createOrder - β Inconsistent response shapes β always use the same envelope
- β Returning 200 for errors β use proper status codes
- β Exposing internal IDs or stack traces β sanitize all error responses
- β No pagination on list endpoints β unbounded queries kill databases
- β Missing rate limiting β one aggressive client can take down your API
- β Accepting unvalidated input β every field must be validated and sanitized
- β No request IDs β debugging becomes impossible without correlation
- β Breaking changes without versioning β always version your API
- β Documentation that drifts from implementation β generate from code/spec
Adapted from buildwithclaude by Dave Poon (MIT)
# Supported AI Coding Agents
This skill is compatible with the SKILL.md standard and works with all major AI coding agents:
Learn more about the SKILL.md standard and how to use these skills with your preferred AI coding agent.