history Last updated

An API specification is a contract, so it must be precise about the parts people argue over later: error semantics, idempotency, pagination, versioning and deprecation. The schema itself belongs in OpenAPI; this document holds the decisions the schema cannot express.

When to use it

  • Before another team, or an external partner, writes code against your service.
  • When a public or partner-facing interface needs a support and deprecation commitment.
  • Alongside — not instead of — a machine-readable OpenAPI or protobuf definition.

What it must answer

  • What resources exist, and what operations are allowed on each?
  • How does a caller authenticate, and what is it authorised to do?
  • What errors can occur, how are they shaped, and which are retryable?
  • What are the rate limits, the pagination rules, and the versioning policy?

Template

API Specification: <Service> v<major>

FieldValue
Base URL
Machine-readable speclink to openapi.yaml
Owner team
Support channel
StabilityExperimental / Stable / Deprecated

1. Purpose and audience

Who calls this, for what.

2. Authentication and authorisation

Scheme, token lifetime, scopes, and what each scope permits.

3. Conventions

Media types, date and money formats, casing, null vs absent, time zones,
correlation header.

4. Resources

<Resource>

MethodPathPurposeIdempotent?Required scope

Request and response examples for each non-trivial operation.

5. Errors

HTTPCodeMeaningRetryable?Caller action

Error body shape, and the rule for machine-readable codes.

6. Idempotency and concurrency

Idempotency keys, optimistic concurrency (ETag / version), replay window.

7. Pagination, filtering, sorting

8. Rate limits and quotas

Limits, headers returned, behaviour at the limit.

9. Versioning and deprecation

How breaking changes are made, notice period, sunset headers.

10. Non-functional commitments

Latency targets, availability, and where they are measured.

11. Changelog

  # API Specification: <Service> v<major>

| Field | Value |
|---|---|
| Base URL | |
| Machine-readable spec | link to openapi.yaml |
| Owner team | |
| Support channel | |
| Stability | Experimental / Stable / Deprecated |


## 1. Purpose and audience
Who calls this, for what.

## 2. Authentication and authorisation
Scheme, token lifetime, scopes, and what each scope permits.

## 3. Conventions
Media types, date and money formats, casing, null vs absent, time zones,
correlation header.

## 4. Resources
### <Resource>
| Method | Path | Purpose | Idempotent? | Required scope |
|---|---|---|---|---|


Request and response examples for each non-trivial operation.

## 5. Errors
| HTTP | Code | Meaning | Retryable? | Caller action |
|---|---|---|---|---|


Error body shape, and the rule for machine-readable codes.

## 6. Idempotency and concurrency
Idempotency keys, optimistic concurrency (ETag / version), replay window.

## 7. Pagination, filtering, sorting
## 8. Rate limits and quotas
Limits, headers returned, behaviour at the limit.

## 9. Versioning and deprecation
How breaking changes are made, notice period, sunset headers.

## 10. Non-functional commitments
Latency targets, availability, and where they are measured.

## 11. Changelog
  

Worked example

API Specification: Provisioning API v1

FieldValue
Base URLhttps://provisioning.internal.example.com/v1
Machine-readable spec/openapi.yaml
Owner teamPlatform Identity
StabilityStable since 2026-09-01

1. Purpose and audience

Lets internal callers create and track access provisioning requests, and lets
line managers approve entitlements that fall outside a joiner’s standard role
bundle. Its consumers are the service desk tooling, the People Operations
admin UI, and the manager approval app. It is not exposed outside the
corporate network and is not intended for bulk reporting — use the daily audit
export for that.

2. Authentication and authorisation

OAuth 2.0 client credentials. Tokens live 15 minutes. Scopes:

ScopePermits
provisioning.readRead requests and their status
provisioning.writeCreate requests, re-resolve pending requests
provisioning.approveApprove or reject non-standard entitlements

Approval requires a user-delegated token; a service token with
provisioning.approve is rejected, because approval must be attributable to a
person (audit driver D1).

3. Conventions

JSON only (application/json). Timestamps are RFC 3339 in UTC with a trailing
Z. Field names are snake_case. Absent means “not supplied”; null means
“explicitly cleared” — the two are not interchangeable on PATCH. Every request
should carry X-Correlation-Id; if absent, the service mints one and returns
it in the response.

4. Resources

Provisioning requests

MethodPathPurposeIdempotent?Scope
POST/requestsCreate a provisioning requestYes, with Idempotency-Keyprovisioning.write
GET/requests/{id}Fetch one requestYesprovisioning.read
GET/requestsList, filteredYesprovisioning.read
POST/requests/{id}/approveApprove pending entitlementsYes, with If-Matchprovisioning.approve
POST/requests/{id}/rejectReject pending entitlementsYes, with If-Matchprovisioning.approve
POST/requests/{id}/re-resolveRe-resolve against the current bundleNoprovisioning.write

POST /requests

  {
  "employee_id": "E-40219",
  "job_code": "ENG-3",
  "start_date": "2026-10-05",
  "manager_employee_id": "E-11804",
  "additional_entitlements": ["repo:payments:write"]
}
  

201 Created

  {
  "id": "req_01J9F3K2",
  "state": "AWAITING_APPROVAL",
  "bundle_version": "eng-3@17",
  "entitlements": [
    {"name": "sso:default", "source": "bundle", "state": "PENDING"},
    {"name": "repo:payments:write", "source": "additional", "state": "AWAITING_APPROVAL"}
  ],
  "created_at": "2026-09-18T09:12:44Z"
}
  

5. Errors

Body shape:

  {"code": "bundle_not_found", "message": "...", "correlation_id": "...", "details": {}}
  
HTTPCodeMeaningRetryable?Caller action
400invalid_requestSchema or field validation failedNoFix the request
401unauthenticatedMissing or expired tokenAfter refreshGet a new token
403insufficient_scopeToken lacks the scope, or is a service token on /approveNoUse a delegated token
404not_foundRequest ID unknownNo—
409state_conflictRequest is not in a state that allows this actionNoRe-read and decide
412precondition_failedIf-Match did not match current versionNoRe-read, re-apply
422bundle_not_foundJob code has no bundleNoRoute to People Ops
429rate_limitedQuota exceededYes, after Retry-AfterBack off
503target_unavailableA downstream target system is downYesRetry with backoff; request is queued regardless

code values are stable and part of the contract; message is human-readable
and may change without notice. Never branch on message.

6. Idempotency and concurrency

POST /requests accepts Idempotency-Key; the same key with the same body
returns the original 201 for 24 hours. The same key with a different body
returns 409 idempotency_key_reuse. Approve and reject require If-Match with
the request’s ETag, so two managers cannot decide simultaneously.

7. Pagination, filtering, sorting

GET /requests is cursor paginated. Pass limit (default 50, max 200) and
cursor from the previous response’s next_cursor; a null next_cursor
means the last page. Offset paging is not supported, because requests change
state while a caller walks the list and offsets would skip or repeat rows.

Filters: state, employee_id, job_code, created_after, created_before.
Repeated values for one filter are OR-ed, different filters are AND-ed.
Sorting is fixed at created_at descending; it is not a client choice, which
keeps the cursor implementation honest.

8. Rate limits and quotas

600 requests per minute per client. Responses carry RateLimit-Limit,
RateLimit-Remaining and RateLimit-Reset. Bulk import is exempt but capped
at one concurrent job per client.

9. Versioning and deprecation

The major version is in the path. Additive changes (new optional fields, new
enum values on output) ship within v1 — clients must ignore unknown fields and
tolerate unknown enum values. Breaking changes require v2, with both versions
served for at least 6 months. Deprecated endpoints return Deprecation and
Sunset headers for the whole notice period.

10. Non-functional commitments

p95 < 300 ms for reads and < 800 ms for writes, measured at the service’s
ingress. 99.5% monthly availability. These are the numbers the owning team is
paged against.

11. Changelog

VersionDateChange
1.14.02026-11-12Added POST /requests/{id}/re-resolve and RateLimit-* headers; bundle_version is now always present
1.12.02026-10-01Deprecated X-Request-Trace in favour of X-Correlation-Id
1.9.02026-09-01Declared stable; no breaking change will ship inside v1
1.0.02026-07-14First release
  # API Specification: Provisioning API v1

| Field | Value |
|---|---|
| Base URL | https://provisioning.internal.example.com/v1 |
| Machine-readable spec | /openapi.yaml |
| Owner team | Platform Identity |
| Stability | Stable since 2026-09-01 |


## 1. Purpose and audience
Lets internal callers create and track access provisioning requests, and lets
line managers approve entitlements that fall outside a joiner's standard role
bundle. Its consumers are the service desk tooling, the People Operations
admin UI, and the manager approval app. It is not exposed outside the
corporate network and is not intended for bulk reporting — use the daily audit
export for that.

## 2. Authentication and authorisation
OAuth 2.0 client credentials. Tokens live 15 minutes. Scopes:

| Scope | Permits |
|---|---|
| provisioning.read | Read requests and their status |
| provisioning.write | Create requests, re-resolve pending requests |
| provisioning.approve | Approve or reject non-standard entitlements |


Approval requires a user-delegated token; a service token with
provisioning.approve is rejected, because approval must be attributable to a
person (audit driver D1).

## 3. Conventions
JSON only (`application/json`). Timestamps are RFC 3339 in UTC with a trailing
`Z`. Field names are `snake_case`. Absent means "not supplied"; `null` means
"explicitly cleared" — the two are not interchangeable on PATCH. Every request
should carry `X-Correlation-Id`; if absent, the service mints one and returns
it in the response.

## 4. Resources

### Provisioning requests
| Method | Path | Purpose | Idempotent? | Scope |
|---|---|---|---|---|
| POST | /requests | Create a provisioning request | Yes, with Idempotency-Key | provisioning.write |
| GET | /requests/{id} | Fetch one request | Yes | provisioning.read |
| GET | /requests | List, filtered | Yes | provisioning.read |
| POST | /requests/{id}/approve | Approve pending entitlements | Yes, with If-Match | provisioning.approve |
| POST | /requests/{id}/reject | Reject pending entitlements | Yes, with If-Match | provisioning.approve |
| POST | /requests/{id}/re-resolve | Re-resolve against the current bundle | No | provisioning.write |


POST /requests

```json
{
  "employee_id": "E-40219",
  "job_code": "ENG-3",
  "start_date": "2026-10-05",
  "manager_employee_id": "E-11804",
  "additional_entitlements": ["repo:payments:write"]
}
```

201 Created

```json
{
  "id": "req_01J9F3K2",
  "state": "AWAITING_APPROVAL",
  "bundle_version": "eng-3@17",
  "entitlements": [
    {"name": "sso:default", "source": "bundle", "state": "PENDING"},
    {"name": "repo:payments:write", "source": "additional", "state": "AWAITING_APPROVAL"}
  ],
  "created_at": "2026-09-18T09:12:44Z"
}
```

## 5. Errors
Body shape:

```json
{"code": "bundle_not_found", "message": "...", "correlation_id": "...", "details": {}}
```

| HTTP | Code | Meaning | Retryable? | Caller action |
|---|---|---|---|---|
| 400 | invalid_request | Schema or field validation failed | No | Fix the request |
| 401 | unauthenticated | Missing or expired token | After refresh | Get a new token |
| 403 | insufficient_scope | Token lacks the scope, or is a service token on /approve | No | Use a delegated token |
| 404 | not_found | Request ID unknown | No | — |
| 409 | state_conflict | Request is not in a state that allows this action | No | Re-read and decide |
| 412 | precondition_failed | If-Match did not match current version | No | Re-read, re-apply |
| 422 | bundle_not_found | Job code has no bundle | No | Route to People Ops |
| 429 | rate_limited | Quota exceeded | Yes, after Retry-After | Back off |
| 503 | target_unavailable | A downstream target system is down | Yes | Retry with backoff; request is queued regardless |


`code` values are stable and part of the contract; `message` is human-readable
and may change without notice. Never branch on `message`.

## 6. Idempotency and concurrency
POST /requests accepts `Idempotency-Key`; the same key with the same body
returns the original 201 for 24 hours. The same key with a *different* body
returns 409 `idempotency_key_reuse`. Approve and reject require `If-Match` with
the request's ETag, so two managers cannot decide simultaneously.

## 7. Pagination, filtering, sorting
`GET /requests` is cursor paginated. Pass `limit` (default 50, max 200) and
`cursor` from the previous response's `next_cursor`; a null `next_cursor`
means the last page. Offset paging is not supported, because requests change
state while a caller walks the list and offsets would skip or repeat rows.

Filters: `state`, `employee_id`, `job_code`, `created_after`, `created_before`.
Repeated values for one filter are OR-ed, different filters are AND-ed.
Sorting is fixed at `created_at` descending; it is not a client choice, which
keeps the cursor implementation honest.

## 8. Rate limits and quotas
600 requests per minute per client. Responses carry `RateLimit-Limit`,
`RateLimit-Remaining` and `RateLimit-Reset`. Bulk import is exempt but capped
at one concurrent job per client.

## 9. Versioning and deprecation
The major version is in the path. Additive changes (new optional fields, new
enum values on output) ship within v1 — clients must ignore unknown fields and
tolerate unknown enum values. Breaking changes require v2, with both versions
served for at least 6 months. Deprecated endpoints return `Deprecation` and
`Sunset` headers for the whole notice period.

## 10. Non-functional commitments
p95 < 300 ms for reads and < 800 ms for writes, measured at the service's
ingress. 99.5% monthly availability. These are the numbers the owning team is
paged against.

## 11. Changelog
| Version | Date | Change |
|---|---|---|
| 1.14.0 | 2026-11-12 | Added `POST /requests/{id}/re-resolve` and `RateLimit-*` headers; `bundle_version` is now always present |
| 1.12.0 | 2026-10-01 | Deprecated `X-Request-Trace` in favour of `X-Correlation-Id` |
| 1.9.0 | 2026-09-01 | Declared stable; no breaking change will ship inside v1 |
| 1.0.0 | 2026-07-14 | First release |
  

Common mistakes

  • Documenting only the happy path. Consumers spend most of their integration effort on errors. The error table is the most-read section of any API document.
  • No retryability guidance. Without it, callers either retry nothing (and drop work) or retry everything (and duplicate it).
  • message used as a machine-readable code. State explicitly which field is stable, or clients will parse the prose and break on a typo fix.
  • Versioning policy left unstated. “We will not break you” is not a policy. Notice period and sunset mechanics are.
  • Two sources of truth. If this document and the OpenAPI file disagree, generate what you can from the schema and keep only the decisions here.

Last updated 23 Aug 2026, 17:59 -0500. history