Request conventions
A small set of rules applies to nearly every call in the contract. Getting them right once, in your HTTP layer, removes most of the failure modes you would otherwise meet one endpoint at a time.
The version handshake
Before its first versioned call, a client negotiates an API major against the unversioned
GET /api/version. It advertises every major it implements, in descending
preference order:
GET /api/version
Silicon-IAM-Supported-API-Versions: v1
{
"service": "silicon-iam",
"selected_api_version": "v1",
"supported_api_versions": ["v1"],
"build": "0.1.0",
"commit": "…"
}
IAM selects the highest mutually supported version and returns it in both
selected_api_version and the Silicon-IAM-API-Version header. When
there is no common version it answers 406 api_version_not_acceptable with its own
catalog in details.
/api/v1/version is a version-specific diagnostic. It is not the
handshake, and calling it instead skips every check above.
Contract catalog and lifecycle
The first official API contract is v1. GET /api/v1/contracts returns items with each major's version, status, released_at, deprecated_at, last_requested_at, sunset_at, and compatibility classification, together with the registry's lifecycle policy. API majors are independent of SDK and CLI package versions.
Breaking changes receive a new major version. A contract can be current, deprecated, or sunset. Deprecation keeps its existing behavior available while clients move to a supported major. Only deprecated contracts can sunset automatically, and only after seven complete days without requests since the later of deprecation or last activity. Activity writes are coalesced within a minute, so retirement includes an additional conservative minute. Current v1 never sunsets solely because it is idle.
Versioned responses identify the path's contract in Silicon-IAM-API-Version. Deprecated responses also carry Deprecation: true. An unknown major returns 404 unknown_api_version; a retired major returns 410 api_version_sunset. If a request supplies Silicon-IAM-Supported-API-Versions, the path's major must be among its supported versions, otherwise IAM returns 406 api_version_not_supported. Negotiate and verify a supported contract before proceeding.
Idempotency
IAM's own failures use a JSON error envelope with a stable code and an optional
request correlation ID. A public edge or reverse proxy can answer before that contract is
reached. An HTML 403 without an IAM envelope is not an IAM membership denial;
record the HTTP status and any request ID, then inspect deployment logs. Do not invent a
request ID or infer a particular firewall rule from that response alone. If a mutation's
outcome is uncertain, retain its original idempotency key.
Every externally initiated mutation requires an Idempotency-Key of 16–255
characters. The server scopes it to the authenticated caller, the route, and a digest of the
request body.
- Repeating an identical validated request returns the stored result, and may carry
Idempotency-Replayed: true. - Changing any canonical field under the same key returns
409 idempotency_conflict. - JSON whitespace and object-key ordering are not significant.
Two deliberate exceptions. OBO proof verification accepts no idempotency key,
never stores a successful response, and answers 409 on every attempt after the
proof is consumed — it is single-use by design. And a response containing a
newly generated secret stays replayable for only ten minutes, rather than the
usual twenty-four hours.
JSON Merge Patch
Use each endpoint’s declared content type. JSON Merge Patch routes consume application/merge-patch+json. For a nullable
property, three wire states have three different meanings:
| JSON state | Meaning |
|---|---|
| Property omitted | Leave the stored value unchanged |
"property": null | Clear a nullable stored value |
"property": value | Replace the stored value |
Use null only where the OpenAPI property is nullable. Do not serialize an absent
nullable optional as null: that turns “leave unchanged” into “clear this field.” A
patch that produces no state change returns its resource-specific stable
409 …_unchanged code rather than incrementing the version or emitting audit and
outbox work.
Optimistic concurrency
Versioned aggregate mutations require a strong If-Match: "{version}" header. Reads
and successful mutations return the version as an ETag; when a body also contains
version, it is the same number.
| Situation | Response |
|---|---|
| Version matches | The mutation applies and the version increments by exactly one |
| Version is stale | 412 version_mismatch |
| Precondition omitted | 428 precondition_required |
Do not retry a 412 automatically. It means the record changed underneath you, and
a blind retry silently overwrites whatever the other party did. Re-read, show the current
state, and let the human decide whether their change still applies.
Pagination
List endpoints use opaque cursors. The maximum page size is 100 and the default is 50.
GET /api/v1/organizations?limit=50&cursor=opaque-value
{
"items": [],
"page": { "next_cursor": null, "has_more": false }
}
Treat cursors as opaque continuation values and reuse them only with the same caller, filters, sort order, and tenant context. Clients must not interpret or construct them. There is no total count, and no way to jump to page seven — an interface that promises either is promising something the API cannot deliver.
Errors
Every JSON error uses one envelope:
{
"error": {
"code": "machine_readable_code",
"message": "Safe human-readable explanation",
"details": {},
"request_id": "trace identifier"
}
}
Branch on code, never on message — the message is prose for a human
and may be reworded without a contract change. Log request_id; it is the fastest
way to have a specific failure investigated.
A login that fails answers with this envelope too. It does not redirect: there is no token to deliver, and the redirect URI came from the caller rather than from a registration, so it is not a trusted place to report an error to.
Status guidance and stable no-op conflict codes are in the error index.
Rate limits
A 429 carries Retry-After, RateLimit-Limit,
RateLimit-Remaining and RateLimit-Reset. Honour
Retry-After; do not invent your own backoff on top of it.
Buckets are keyed across normalised identity, session, purpose, channel and provider. Signup send protection includes a contact-global bucket independent of the temporary signup session, so cycling sessions does not buy extra attempts. Verification-attempt cooldowns are separate from initiation limits, and issuing a new code invalidates the older one.
IP and subnet buckets are deliberately disabled until a deployment defines trusted proxy extraction. Silicon IAM never trusts an arbitrary forwarding header.
Headers worth knowing
| Header | Direction | Meaning |
|---|---|---|
Idempotency-Key | request | Required on external mutations except single-use OBO verification |
X-Testing-Environment-Key | request | Selects an isolated testing environment; normal route credentials still apply |
Silicon-IAM-Supported-API-Versions | request | Implemented API majors for explicit negotiation |
If-Match | request | Strong version precondition, quoted |
X-Step-Up-Token | request | Action-bound reauthentication |
X-Org-ID | request | Introspection only; can never widen authority |
X-Request-ID | both | Accepted when a valid UUID, otherwise generated |
ETag | response | Current aggregate version |
Idempotency-Replayed | response | A stored result was returned |
X-Org-ID must agree with the credential and the existing grant. OBO does not accept this header: its exchange body uses optional org_id to select an already authorized user membership. OBO may cross application-owning organizations. When several user memberships are available, the caller must select one explicitly.