S2S Relay Envelope
Applies to: every AnkaSecure error body produced by a request that an internal service forwarded to another internal service
Content-Type: application/problem+json (RFC 7807)
Some AnkaSecure operations are served by more than one internal service. A tenant-scoped key operation, for example, enters at the Admin API, which forwards it over an internal service-to-service (S2S) call to the Core API. When the internal service rejects the request, the Admin API has to turn that rejection into an error body of its own.
This page is the normative description of how that body is composed. It exists so a client never has to infer the rule — or, worse, parse an error document out of a detail string.
The one rule
detail is always a single human-readable sentence. It is never a JSON document.
Everything a client needs is a named member of the problem body — read directly, never re-parsed out of a string. On the Admin API — the responding service on every relay path described here — extension members (requestId, failedEntries, …) sit one level down inside an extensions object rather than at the top level; that is a fixed, documented location, not a document folded into a value. Placement differs by service, so see Error Index & Overview.
Which members can come from the upstream
Exactly four members are ever taken from an upstream body — type, title, detail and status. That is the complete allow-list: an upstream extension member cannot appear in the response by accident, because the internal view of an upstream problem has no field for one.
| Member | Source | Why |
|---|---|---|
type | the upstream, on the arms listed below | Both services emit into the same https://docs.ankatech.co/errors/<slug> registry, so the slug identifies the same condition regardless of which service detected it. |
title | the upstream, on the same arms | Short human label paired with type. |
detail | the upstream, on a wider set of arms (see below) | The sentence describing the specific rejection. |
status | the upstream | See the propagatable set below. |
instance | the responding service (the one you called) — always | The path of your request. The upstream's internal route is never republished — it belongs to a URI space you do not address, and it can embed identifiers such as a kid. |
extensions.requestId | the responding service — always | The correlation identity of your request. The upstream's own correlation id is never copied, so a body never carries two competing identities. |
timestamp | the responding service — always | When the responding service produced this body. |
title and detail are sanitised before they are re-presented: control characters and line/paragraph separators are stripped, and the text is capped at 512 characters; a sentence that had to be shortened ends in […], so a truncated sentence is distinguishable from a complete one.
On the arms that propagate a type, that type is re-presented only when it is a member of the shared https://docs.ankatech.co/errors/ registry — the same registry this page's index documents. A scheme check alone would not be enough: any https origin could then emit a registry-shaped slug and forge a classification you branch on. Anything outside the registry prefix — a relative reference, http:, javascript:, data:, a userinfo-bearing look-alike authority, or simply another https origin — routes the whole document to the degenerate branch. The upstream detail only arms are independent of that check: they sanitise and relay the sentence whatever the upstream type is, because those arms never publish an upstream type in the first place.
How much is propagated depends on the operation
Full propagation of all four members is not universal. Each operation decides per status how much of the upstream rejection it re-presents, and there are three behaviours:
| Behaviour | What you receive | Where it applies |
|---|---|---|
| Full propagation | upstream type, title, detail and status | The generic client-error arm of key creation, key rotation and rotation admissibility — that is, every 4xx those three operations do not handle specifically. Also three specifically-handled 422 arms: the rotation-policy refusal on key rotation, keystore import, and key import. |
Upstream detail only | upstream detail; type and title are the responding service's own generic slug for the status | Rotation admissibility on 404; keystore import on a 4xx other than 409/404/422; keystore analysis on any 4xx; key import on a 4xx other than 409/404/422. |
| No propagation | an operation-specific sentence authored by the responding service; the upstream body is discarded | 409 on key creation; 409 and 404 on key rotation, keystore import and key import. |
A key-protection refusal pre-empts rows 2 and 3. Four problem types — not configured, unavailable, misconfigured and rate limited — are recognised by the responding service before either of those two behaviours is reached, and are re-presented with their own status and header posture rather than as that operation's generic sentence or generic slug. So a 409 on keystore import or key import is the admin-authored duplicate-KID sentence only when the upstream type is not one of those four, and a 429 on keystore import, key import or keystore analysis is relayed as key-protection-rate-limited with its Retry-After rather than reduced to a 400. Every other status on those arms behaves exactly as the table states.
Two consequences of row 1's "do not handle specifically" are worth stating outright, because they are the cases a reader is most likely to assume the wrong way round. Key creation declares no 404 arm, so an upstream 404 reaches its generic arm and is fully propagated — it is not one of the admin-authored sentences. Likewise rotation admissibility declares no 409 arm, so an upstream 409 there is fully propagated too.
A full propagation arm propagates the four members only when the upstream body is itself a usable RFC 7807 document; when it is not, the same arm falls through to the degenerate branch (or, for a structured keystore-import or key-import 422, to its counting-sentence refinement). The upstream detail only arms are unaffected by that fall-through — they never publish an upstream type in the first place.
The consequence worth planning for: on an upstream detail only arm, type is a generic slug for the status (for example .../errors/not-found) while detail describes a specific upstream condition. Classify on type and show detail — but do not expect the slug to be as specific as the sentence on those arms.
Independently of all three, instance, extensions.requestId and timestamp are always the responding service's own, and detail is always a single sentence.
Which statuses are relayed
Only these four upstream statuses are re-presented as a client fault:
- 400 — Invalid Input
- 404 — Resource Not Found
- 409 — Conflict
- 422 — Unprocessable Entity
Every other upstream status becomes Upstream Service Unavailable (502). That deliberately includes 401 and 403: an internal service refusing the platform's own S2S credential is a platform-side misconfiguration, and reporting it as a 401, a 403, or a client 400 would misattribute it to your request.
Relayed rejection — example
{
"type": "https://docs.ankatech.co/errors/rotation-purpose-violation",
"title": "Rotation Purpose Violation",
"status": 422,
"detail": "The requested rotation would change the key purpose, which the tenant policy forbids.",
"instance": "/api/v3/admin/tenants/2f1c9d84-6b2e-4d3a-9f57-0a1b2c3d4e5f/keys/my-mlkem-key/rotate",
"timestamp": 1751500800,
"extensions": {
"requestId": "550e8400-e29b-41d4-a716-446655440000"
}
}
This is a full propagation arm: type, title, detail and status are the Core API's. instance, timestamp and extensions.requestId are the Admin API's. Nothing in the body reveals the Core API's internal route.
The degenerate branch
If the upstream body cannot be trusted, the responding service does not guess and does not forward bytes. It emits its own error instead. This branch is taken when the upstream body:
- is absent, empty, or not valid JSON;
- is not an RFC 7807 object;
- carries no
type, or atypeoutside the sharedhttps://docs.ankatech.co/errors/registry (which also covers a relative reference, a non-httpsscheme, a userinfo-bearing look-alike authority, a value carrying a control character, and a value over 512 characters); - carries a
detailthat is blank after sanitisation.
Refinement — a structured keystore-import rejection
One refinement applies to a 422 whose body is a structured keystore-import result. Such a body is not a problem document, so the checks above route it here — but it does carry a usable per-entry failure list. When at least one entry is extracted, the responding service emits an admin-authored counting sentence instead of the "no usable problem description" wording, which would otherwise deny the structure the same response publishes:
The key-management service rejected the request. This response carries N failed keystore entries under extensions.failedEntries.
N is the number of entries published under extensions.failedEntries — a count of this response, never an upstream total, since the list is capped at 50 — and the sentence reads entry in the singular when N is 1. type and title are still the responding service's own. When no entry can be extracted, the branch is unchanged and the sentence in the example below applies.
Degenerate rejection — example
{
"type": "https://docs.ankatech.co/errors/upstream-rejected-request",
"title": "Upstream Rejected Request",
"status": 422,
"detail": "The key-management service rejected the request and returned no usable problem description.",
"instance": "/api/v3/admin/tenants/2f1c9d84-6b2e-4d3a-9f57-0a1b2c3d4e5f/keys/import-keystore",
"timestamp": 1751500800,
"extensions": {
"requestId": "550e8400-e29b-41d4-a716-446655440000"
}
}
The status is still the upstream's (it is within the propagatable set), but every text member is the responding service's own. No upstream byte appears in the body. This example carries no failedEntries member, which is why detail is the "no usable problem description" wording rather than the counting sentence above.
Structured failure lists
When a rejection has per-item detail — a keystore import in which every entry failed, for example — that detail is published as a named extension member at extensions.failedEntries, never as text inside detail. The list is capped at 50 entries so the response stays bounded. Note that this member is populated on the degenerate branch too: the per-item data is projected from the upstream body's own failure list even when that body carries nothing usable as a problem document — and when it is populated, detail is the counting sentence described above rather than the "no usable problem description" wording. See the failedEntries example on Unprocessable Entity.
What this means for a client
- Read
typeto decide what happened. It is the stable, machine-readable classifier. On the upstreamdetailonly arms above it is a generic slug for the status rather than a specific condition, so pair it with the status rather than assuming it is always the narrowest label. - Show
detailto a human. Do not match on its wording — it is prose and may be reworded. - Never parse
detailas JSON. It is a sentence, in every branch, including the degenerate one. - Use
instanceandextensions.requestId(or theX-Request-Idheader) for support correlation. They always describe your own request. - Read extension members from
extensions, not from the top level.
Related Errors
- Upstream Service Unavailable (502) — where every non-relayed upstream status lands.
- Upstream Service Timeout (504) — the upstream did not answer within the deadline.
- Error Index & Overview — the complete registry of
typeslugs.