---
title: "skill by agenticluke · skilld"
canonical_url: "https://skilld.dev/gh/agenticluke/rest-api-designer-plus"
meta:
  description: "Design clear REST APIs with good names, status codes, page rules, filters, errors, versions, access checks, and rate limits. From agenticluke/rest-api-designer-plus."
  "og:description": "Design clear REST APIs with good names, status codes, page rules, filters, errors, versions, access checks, and rate limits. From agenticluke/rest-api-designer-plus."
  "og:title": "skill by agenticluke"
  "twitter:description": "Design clear REST APIs with good names, status codes, page rules, filters, errors, versions, access checks, and rate limits. From agenticluke/rest-api-designer-plus."
  "twitter:title": "skill by agenticluke"
---

`

[All skills](https://skilld.dev/skills)

[![agenticluke avatar](https://skilld.dev/_img/avatar?url=https%3A%2F%2Fgithub.com%2Fagenticluke.png%3Fsize%3D96)](https://skilld.dev/gh/agenticluke)

# **/skill**

[@7fa8c8a](https://github.com/agenticluke/rest-api-designer-plus/commit/7fa8c8aaee3d4e4ed3993b61c2e379dc0ab0113f "Your agent reads SKILL.md at commit 7fa8c8a")

by [agenticluke](https://skilld.dev/gh/agenticluke)· [agenticluke](https://skilld.dev/gh/agenticluke)/ [rest-api-designer-plus](https://skilld.dev/gh/agenticluke/rest-api-designer-plus)

Design clear REST APIs with good names, status codes, page rules, filters, errors, versions, access checks, and rate limits.

- 1 file
- 15.3 KB
- Updated last month
- [GitHub](https://github.com/agenticluke/rest-api-designer-plus/blob/7fa8c8aaee3d4e4ed3993b61c2e379dc0ab0113f/skill/SKILL.md "View SKILL.md on GitHub")

## SKILL.md

15.3 KB

**≈33** tokens always: the name and description. **≈3.9k** when used: this file.

## REST API Design

> Original work by ECC. Credit to ECC for the ideas and base guide.

Use this guide to make REST APIs that are clear, safe, and easy to use.

### When to Use This Skill

Use this skill when you:

- Add a new API route.
- Review an API contract.
- Add pages, filters, search, or sort.
- Add error handling.
- Plan API versions.
- Build a public or partner API.

### Start With the Contract

Before you write code, list:

1. The resource and URL.
2. The request fields.
3. The response fields.
4. The status codes.
5. The access rules.
6. The page and sort rules.
7. The rate limit.
8. The errors a client may get.

Keep names and response shapes the same across all routes.

### Resource Design

#### URL Rules

Use plural nouns. Use lowercase words. Join words with hyphens.

```
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
```

Use a nested URL when one resource belongs to another:

```
GET  /api/v1/users/:id/orders
POST /api/v1/users/:id/orders
```

Keep nesting short. Do not go past two resource levels. Use a top-level route if the child has its own ID:

```
GET /api/v1/orders/:id
```

Use an action route only when normal create, read, update, and delete rules do not fit:

```
POST /api/v1/orders/:id/cancel
POST /api/v1/auth/login
POST /api/v1/auth/refresh
```

#### Good and Bad Names

```
# Good
/api/v1/team-members
/api/v1/orders?status=active
/api/v1/users/123/orders

# Bad
/api/v1/getUsers
/api/v1/user
/api/v1/team_members
/api/v1/users/123/getOrders
```

Use one field style in JSON. This guide uses `snake_case`.

### HTTP Methods

| Method | Safe to retry | Changes data | Use |
| --- | ---: | ---: | --- |
| GET | Yes | No | Read data |
| POST | Usually no | Yes | Create data or run an action |
| PUT | Yes | Yes | Replace all fields |
| PATCH | It depends | Yes | Change some fields |
| DELETE | Yes | Yes | Delete data |

A retry is safe only when the same call has the same final result. A PATCH that adds `1` each time is not safe. A PATCH that sets a name is safe.

For POST calls that may be retried, support an idempotency key:

```
Idempotency-Key: 7bb1c5c0-52a1-4f18-8af8-55ebf85e13fd
```

Store the key with the first result for a set time. Return that result for a repeat call.

### Status Codes

#### Success

```
200 OK          GET, PUT, or PATCH with a body
201 Created     A new resource was made
202 Accepted    Work will finish later
204 No Content  Success with no body
```

For `201 Created`, add a `Location` header:

```
HTTP/1.1 201 Created
Location: /api/v1/users/abc-123
```

For `202 Accepted`, return a way to check the work:

```
{
  "data": {
    "job_id": "job-123",
    "status": "pending",
    "status_url": "/api/v1/jobs/job-123"
  }
}
```

Do not send a body with `204 No Content`.

#### Client Errors

```
400 Bad Request           Bad JSON or bad request form
401 Unauthorized          Login data is missing or not valid
403 Forbidden             The caller is known but lacks access
404 Not Found             The resource does not exist
405 Method Not Allowed    The HTTP method is not allowed
409 Conflict              A duplicate or state clash happened
412 Precondition Failed   A write used an old version
415 Unsupported Media Type The body type is not allowed
422 Unprocessable Content The JSON is valid but its values are not
429 Too Many Requests     The rate limit was passed
```

Use either `400` or `422` for field checks. Pick one rule and use it on every route.

A private API may return `404` instead of `403` when this helps hide whether a resource exists.

#### Server Errors

```
500 Internal Server Error  An unknown server fault
502 Bad Gateway            A service used by this API failed
503 Service Unavailable    A short outage or heavy load
504 Gateway Timeout        A service used by this API took too long
```

Add `Retry-After` to `429` and short-term `503` replies when you know when to retry.

Do not show stack traces, SQL, file paths, keys, tokens, or private data.

### Response Shapes

#### One Resource

```
{
  "data": {
    "id": "abc-123",
    "email": "alice@example.com",
    "name": "Alice",
    "created_at": "2025-01-15T10:30:00Z"
  }
}
```

Use ISO 8601 dates in UTC:

```
2025-01-15T10:30:00Z
```

Use strings for IDs. This avoids number size bugs in some tools.

#### A List

```
{
  "data": [
    { "id": "abc-123", "name": "Alice" },
    { "id": "def-456", "name": "Bob" }
  ],
  "meta": {
    "total": 142,
    "page": 1,
    "per_page": 20,
    "total_pages": 8
  },
  "links": {
    "self": "/api/v1/users?page=1&per_page=20",
    "next": "/api/v1/users?page=2&per_page=20",
    "last": "/api/v1/users?page=8&per_page=20"
  }
}
```

Return an empty list as `[]`. Do not use `null`.

#### An Error

```
{
  "error": {
    "code": "validation_error",
    "message": "Request validation failed",
    "request_id": "req-789",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address",
        "code": "invalid_format"
      }
    ]
  }
}
```

Rules for errors:

- Keep `code` short and stable.
- Make `message` safe for users.
- Put field errors in `details`.
- Add a request ID for support.
- Never put secrets in an error.
- Do not make clients read the message to learn the error type.

Choose one response style for the whole API:

```
interface ApiResponse<T> {
  data: T;
  meta?: PaginationMeta;
  links?: PaginationLinks;
}

interface ApiError {
  error: {
    code: string;
    message: string;
    request_id?: string;
    details?: FieldError[];
  };
}
```

### Page Rules

Always use a fixed sort. Add a unique last sort field, such as `id`. Without this, rows may move or repeat.

Set a default and a hard limit:

```
Default limit: 20
Max limit: 100
```

Reject bad values, such as a negative page or a limit over the max.

#### Page and Offset

```
GET /api/v1/users?page=2&per_page=20
```

```
SELECT *
FROM users
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20;
```

Use this for small lists and screens that need page numbers. Large offsets can be slow. New rows can also cause repeats or missed rows.

#### Cursor

```
GET /api/v1/users?cursor=opaque-value&limit=20
```

Read one extra row to learn if another page exists:

```
SELECT *
FROM users
WHERE (created_at, id) < (:cursor_created_at, :cursor_id)
ORDER BY created_at DESC, id DESC
LIMIT 21;
```

```
{
  "data": [],
  "meta": {
    "has_next": true,
    "next_cursor": "opaque-value"
  }
}
```

The cursor must hold all sort fields. Sign or lock it so clients cannot change it. Treat it as a secret value, but do not place private data inside it.

Return `400 Bad Request` for a bad or old cursor.

Use cursor pages for feeds, large lists, and public APIs. Cursor pages do not support a direct jump to page 50.

### Filters, Sort, and Search

Only allow known fields and actions. Never place raw client input into SQL.

#### Filters

```
GET /api/v1/orders?status=active&customer_id=abc-123
GET /api/v1/products?price[gte]=10&price[lte]=100
GET /api/v1/orders?created_at[after]=2025-01-01
GET /api/v1/products?category=electronics,clothing
GET /api/v1/orders?customer.country=US
```

State how commas, spaces, empty values, and letter case work. URL encode all special signs.

#### Sort

```
GET /api/v1/products?sort=-created_at
GET /api/v1/products?sort=-featured,price,-created_at
```

A leading `-` means high to low. Add `id` as the last sort field when the client leaves it out.

Reject unknown sort fields.

#### Search

```
GET /api/v1/products?q=wireless%20headphones
GET /api/v1/users?email=alice
```

Set a max search length. State if search ignores letter case. Escape search text before use.

#### Pick Fields

```
GET /api/v1/users?fields=id,name,email
GET /api/v1/orders?fields=id,total,status&include=customer.name
```

Allow only safe fields. Do not let `fields` expose passwords, tokens, private notes, or hidden admin data.

Set a limit on linked data in `include`. This stops very large replies and slow calls.

### Request Checks

Check these before work starts:

- The body is valid JSON.
- `Content-Type` is allowed.
- Required fields exist.
- Text and list sizes are within limits.
- Values have the right type and range.
- IDs have the right form.
- Unknown fields follow one clear rule.
- The caller has access to each resource.

Reject very large bodies before parsing them.

Do not trust client fields such as `user_id`, `role`, `price`, or `is_admin`. Get trusted values from the login data or server records.

For PATCH, state how `null` works. It may clear a field, or it may be banned. Do not leave this unclear.

### Login and Access

Use TLS for every API call.

```
GET /api/v1/users
Authorization: Bearer token-value
```

For service calls:

```
GET /api/v1/data
X-API-Key: key-value
```

Do not place keys or tokens in URLs. URLs may be saved in logs and browser history.

Check both login and access:

```
app.get("/api/v1/orders/:id", async (req, res) => {
  const order = await Order.findById(req.params.id);

  if (!order) {
    return res.status(404).json({
      error: { code: "not_found", message: "Order not found" }
    });
  }

  if (order.userId !== req.user.id) {
    return res.status(403).json({
      error: { code: "forbidden", message: "Access denied" }
    });
  }

  return res.json({ data: order });
});
```

Check access on every object. Do not rely only on a user ID from the URL.

### Safe Updates

Two users may edit the same item at once. Use a version value or `ETag` to stop lost updates:

```
GET /api/v1/users/abc-123
ETag: "v7"
```

```
PATCH /api/v1/users/abc-123
If-Match: "v7"
Content-Type: application/json
```

Return `412 Precondition Failed` if the item changed after the client read it.

For delete calls, decide if the item is fully removed or only marked as deleted. State this in the API guide.

### Rate Limits

Rate limits must use a clear key, such as IP, user, API key, or service.

```
HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 95
RateLimit-Reset: 60
```

When the limit is passed:

```
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
```

```
{
  "error": {
    "code": "rate_limit_exceeded",
    "message": "Too many requests. Try again in 60 seconds."
  }
}
```

These are sample limits. Pick limits from real load tests and user needs:

| Group | Sample limit | Count by |
| --- | ---: | --- |
| Guest | 30 each minute | IP |
| Signed in | 100 each minute | User |
| Paid | 1,000 each minute | API key |
| Internal | 10,000 each minute | Service |

Set lower limits on costly routes, login tries, password reset, search, and file upload.

### Caching

Use cache rules only when they are safe.

```
Cache-Control: private, max-age=60
ETag: "user-abc-123-v7"
```

Use `Cache-Control: no-store` for secrets and other private data that must not be saved.

Add all fields that change the reply to the cache key. This may include the user, query, language, and API version.

Never let one user receive another user's cached data.

### API Versions

Path versions are clear and easy to test:

```
/api/v1/users
/api/v2/users
```

Start with `/api/v1`. Make a new major version only for a change that can break a client.

Changes that often do not need a new version:

- Add a new route.
- Add an optional request field.
- Add a response field, if clients are told to ignore unknown fields.
- Add an optional query field.

Changes that need a new version:

- Remove or rename a field.
- Change a field type.
- Change a field from optional to required.
- Change the meaning of a field.
- Change a URL.
- Change login rules.
- Change the default sort in a way that breaks page use.

Keep no more than two active major versions when you can. Give public API users at least six months of notice when possible.

Send clear end dates:

```
Deprecation: true
Sunset: Thu, 01 Jan 2026 00:00:00 GMT
Link: <https://api.example.com/migrate-v2>; rel="deprecation"
```

After the end date, return `410 Gone` only if the route is truly removed.

### Concrete Example

Task: Add an endpoint that creates a user.

Contract:

```
POST /api/v1/users
Content-Type: application/json
Idempotency-Key: 7bb1c5c0-52a1-4f18-8af8-55ebf85e13fd
```

Request:

```
{
  "email": "alice@example.com",
  "name": "Alice"
}
```

Success:

```
HTTP/1.1 201 Created
Location: /api/v1/users/abc-123
Content-Type: application/json
```

```
{
  "data": {
    "id": "abc-123",
    "email": "alice@example.com",
    "name": "Alice",
    "created_at": "2025-01-15T10:30:00Z"
  }
}
```

Bad email:

```
HTTP/1.1 422 Unprocessable Content
Content-Type: application/json
```

```
{
  "error": {
    "code": "validation_error",
    "message": "Request validation failed",
    "details": [
      {
        "field": "email",
        "message": "Must be a valid email address",
        "code": "invalid_format"
      }
    ]
  }
}
```

Email already used:

```
HTTP/1.1 409 Conflict
Content-Type: application/json
```

```
{
  "error": {
    "code": "email_in_use",
    "message": "That email address is already in use"
  }
}
```

TypeScript route:

```
import { z } from "zod";
import { NextRequest, NextResponse } from "next/server";

const createUserSchema = z.object({
  email: z.string().email().max(254),
  name: z.string().trim().min(1).max(100),
}).strict();

export async function POST(req: NextRequest) {
  let body: unknown;

  try {
    body = await req.json();
  } catch {
    return NextResponse.json(
      {
        error: {
          code: "bad_json",
          message: "The request body must be valid JSON",
        },
      },
      { status: 400 }
    );
  }

  const parsed = createUserSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      {
        error: {
          code: "validation_error",
          message: "Request validation failed",
          details: parsed.error.issues.map((issue) => ({
            field: issue.path.join("."),
            message: issue.message,
            code: issue.code,
          })),
        },
      },
      { status: 422 }
    );
  }

  const found = await findUserByEmail(parsed.data.email);

  if (found) {
    return NextResponse.json(
      {
        error: {
          code: "email_in_use",
          message: "That email address is already in use",
        },
      },
      { status: 409 }
    );
  }

  const user = await createUser(parsed.data);

  return NextResponse.json(
    { data: user },
    {
      status: 201,
      headers: {
        Location: `/api/v1/users/${user.id}`,
      },
    }
  );
}
```

In real code, also add login checks, rate limits, body size limits, request IDs, and idempotency storage.

### Review List

Before you ship a route, check:

- The URL uses plural nouns.
- The HTTP method fits the work.
- Each result has the right status code.
- Request fields have size and type checks.
- Login and access checks are present.
- Errors use one stable shape.
- Secrets never appear in URLs or errors.
- Lists have limits and a fixed sort.
- Filters and sort fields use allowlists.
- Writes handle retries when needed.
- Updates guard against lost changes.
- Rate limits are set.
- Cache rules do not leak private data.
- Breaking changes use a new version.
- The API contract and examples are up to date.

Source: [SKILL.md on GitHub](https://github.com/agenticluke/rest-api-designer-plus/blob/7fa8c8aaee3d4e4ed3993b61c2e379dc0ab0113f/skill/SKILL.md)

## Third-party checks

No third-party reports yet.

## Provenance

[Signed by skilld at 7fa8c8a.](https://github.com/agenticluke/rest-api-designer-plus/commit/7fa8c8aaee3d4e4ed3993b61c2e379dc0ab0113f "7fa8c8aaee3d4e4ed3993b61c2e379dc0ab0113f") This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub last month.

Activeupdated last month

## Capability

<dl>

<dt>origin</dt>
<dd>ECC</dd>

</dl>

## README badge

![README badge for agenticluke/rest-api-designer-plus](https://skilld.dev/b/agenticluke/rest-api-designer-plus?theme=light&label=0)

## Related skills

-
-
-
-
-
-