Every error the Shield API returns
RootHerald returns string error values, never numeric codes. The body is always { error: string, message?: string } and the HTTP status carries the class. Copy the row into your error handler.
Error shape
Every non-2xx response from the Shield API has the same JSON shape. There is no code, requestId, or policyId field — just an error string and an optional human-readable message:
{
"error": "challenge_expired_or_used",
"message": "..."
}Branch on the error string, not the wording of message. The HTTP status tells you the class at a glance: 400 bad request, 401 auth, 409 conflict, 422 unprocessable, 429 quota, 500 server-side.
Errors
| error | HTTP | Meaning | What to do |
|---|---|---|---|
| invalid_secret_key | 401 | The challenge or verify call arrived without a valid rh_sk_ secret key in the Authorization header. | Send `Authorization: Bearer rh_sk_...` from your backend. Never call these endpoints from the browser or with a publishable rh_pk_ key. |
| invalid_evidence | 400 | The verify body was malformed — missing challengeId, missing evidence, or the evidence carried no device id. | Check the request shape against the API reference. Pass the challengeId you got from POST /attestations/challenge and the full evidence blob the client collected. |
| challenge_expired_or_used | 409 | The challenge id was unparseable, unknown to this tenant, or has already been consumed. Challenges are single-use and live ~5 minutes. | Mint a fresh challenge with POST /attestations/challenge and re-run the client attestation. Do not reuse a challengeId across verify calls. |
| unknown_policy | 422 | The policy id named in the verify request does not exist, was soft-deleted, or belongs to another tenant. RootHerald fails closed — it never silently substitutes a different policy. | Pass a valid built-in policy id (e.g. `rootherald:builtin:strict-hardware`) or one you created via POST /api/v1/admin/policies, or omit `policy` to use your tenant default. |
| quota_exceeded | 429 | The tenant hit its plan quota (free-tier monthly limit or a device account's one-time lifetime grant). The challenge is NOT consumed, so you can retry within quota. | Upgrade the plan on the billing page. The response carries a quota header (`free-monthly-exceeded` or `device-lifetime-exceeded`) and a human-readable `message`. |
| invalid_api_key | 401 | An enroll or activate call (POST /api/v1/devices/enroll, /activate — the host-only leg) arrived without a valid secret key. | These are server→server host relay endpoints; send a valid rh_sk_ secret key. If you're a customer integrating verify, you don't call these directly — the native host does. |
| metering_unavailable | 500 | The verify call reached a billable route with no resolvable tenant context, or usage metering threw. RootHerald fails closed rather than serving an unmetered attestation. | Transient / server-side. Retry with backoff; if it persists, capture the request and contact support. |
A fail verdict is not an error
A device that is cryptographically genuine but doesn't satisfy the active policy — a replayed or bad PCR quote, evidence that can't be bound to the chain-validated AK, a class the policy rejects, or a banned device — is not a 4xx. Those return HTTP 200 with a normal verify response whose verdict.device.verdict is "fail" (or "warn") and an earStatus of "contraindicated". Read the verdict fields, don't catch an exception. See the verdict-fields reference.
There is no policy_rejected, invalid_action, or banned error code — policy and ban outcomes are expressed in the verdict, not as transport errors.
EK / enroll validation failures (free-text 400)
The host-only enroll leg (POST /api/v1/devices/enroll) validates the TPM Endorsement Key certificate chain and the Attestation Key template. When that validation fails, the endpoint returns HTTP 400 with a free-text { "error": "<reason>" } — the human-readable rejection reason (e.g. "AK public area is not valid base64" or an EK-chain message), not a stable machine code. Treat these as diagnostic strings for the enrolling host, not values to branch on.
Internally, EK-chain rejections are categorised by the ChainFailureKind enum in the verifier. These are internal appraisal reasons, NOT wire error codes — they never appear as an error value on the response; they only shape the free-text reason and server-side logs:
EkKeyMismatch— the EK cert's public key doesn't match the EK public area the device presented (CVE-2021-3406 class).EkNotTrusted— the chain built but didn't terminate at an embedded vendor root. Almost always a fake or non-spec TPM.EkChainIncomplete— a complete chain to a trusted root couldn't be assembled and no fetch failed; the chain is genuinely missing intermediates.EkChainFetchFailed— an AIA fetch to the vendor's PKI timed out / 404'd / TLS-failed. Operational, not cryptographic — a retry may succeed.EkCertMissing— no EK certificate was supplied when one was required (capturing it is an elevated step on Windows).Other— a chain-build error outside the categories above.