Skip to content
API

REST API reference

Base URL https://rootherald.io. Every call carries Authorization: Bearer rh_sk_… from your backend; there is no client-side key. The live OpenAPI document is authoritative.

terminalbash
curl -X POST https://rootherald.io/api/v1/attest/challenge \
  -H "Authorization: Bearer rh_sk_…" -H "Content-Type: application/json" \
  -d '{ "ask": ["identity", "posture"], "policy": "rootherald:builtin:strict-hardware" }'

curl https://rootherald.io/api/v1/openapi.json          # the machine-authoritative contract

Endpoints

POST/api/v1/attest/challenge

Background-Check · mint a challenge that carries the ask (C1)

The default server→server flow. Your server calls this with its rh_sk_ secret key to mint a single-use challenge, relays the `challenge` string to its keyless client verbatim, and submits the client's response to /attest/verify. The challenge names the ask (what the device must prove) and the policy it will be judged under. No pre-enrolled RP, no client-side key.

NameInTypeRequiredDescription
AuthorizationheaderstringyesBearer rh_sk_… secret key.
askbody("identity" | "posture" | "key")[]noWhat the device is asked to prove. identity = the enrolled TPM answers the nonce (PCR 7 only, no event log). posture = full PCRs + event log + Secure Boot claims. key = posture plus a freshly created, AK-certified ECC P-256 app key whose wrapped blob is returned to the caller. Omitted ⇒ ["identity", "posture"].
policybodystringnoPolicy the response will be judged under: a tenant-owned policy id or a rootherald:builtin:* name. Verify may not name a weaker policy than this one (422 policy_downgrade). Omitted ⇒ your tenant default (strict-hardware unless changed).
keyPurposebody"sign"noOnly with a key ask. The purpose the app key is certified for. Today the only value is "sign".
deviceHintbodystringnoAdvisory hint identifying the device; recorded for your own correlation, never trusted.

200 with a single-use challenge (5-minute TTL). `challenge` is what the client consumes — relay it verbatim; a client refuses anything that is not an `rhc1.` string. `nonce` is the same 32 bytes, for your own logging. 401 on a missing/invalid secret key · 422 unknown_policy · 400 on an unknown ask value.

{
  "challengeId": "2f9c4a1c-8b…",
  "challenge": "rhc1.<nonce>.<ask>",
  "nonce": "base64…",
  "expiresAt": "2026-06-18T18:09:11Z"
}
POST/api/v1/attest/verify

Background-Check · appraise evidence server→server (C2)

Submit the opaque evidence blob your client produced for the challenge and get the AttestationVerdict back directly in the response. An un-enrolled / failing device is NOT an error — it returns 200 with a fail (or warn) verdict. When the challenge asked for a key, a passing verdict also carries the certified public key; a failing one never does.

NameInTypeRequiredDescription
AuthorizationheaderstringyesBearer rh_sk_… secret key.
challengeIdbodystringyesThe single-use challenge id from /attest/challenge.
evidencebodyobjectyesOpaque evidence blob from the client; forwarded verbatim. For a key ask it includes the TPM2_Certify attestation of the new key over the same nonce.
policybodystringnoTenant-owned policy id/name or a rootherald:builtin:* name. Unknown/foreign names fail closed (422 unknown_policy). Must be at least as strict as the challenge's policy (422 policy_downgrade). Omitted ⇒ the challenge's policy.

200 with the AttestationVerdict. `key` is present only when the challenge asked for one AND the verdict is pass (no pass, no key). 401 invalid key · 422 unknown_policy · 422 policy_downgrade · 409 challenge_expired_or_used · 400 invalid_evidence · 429 quota_exceeded.

{
  "verdict": {
    "acr": "urn:rootherald:device:high",
    "device": {
      "ueid": "2f9c4a1c-8b…",
      "verdict": "pass",
      "earStatus": "affirming",
      "attestationType": "tpm20",
      "secureBootVerified": true,
      "trustworthinessVector": { "hardware": 2, "configuration": 2 }
    }
  },
  "assuranceClaimsMet": ["rootherald:assurance:real-device", "rootherald:assurance:secure-boot"],
  "enrollmentRequired": false,
  "key": {
    "keyId": "k_7d1e…",
    "jwk": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" },
    "purpose": "sign",
    "certifiedAt": "2026-06-18T18:09:14Z"
  }
}
POST/api/v1/attest/enroll

Enroll a device (against a challenge's policy)

First-contact enrollment of a TPM/Secure-Enclave device. The client submits its EK certificate and a TCG-standard AK template; the server validates the EK chain, admits the device under the policy named by the challenge, runs make-credential, and registers a tenant-scoped device. Admission runs BEFORE the sealed credential is issued, so a TPM class the policy refuses (virtual, emulated) is turned away before the client spends its one elevation prompt. Relayed by your backend; produced by the native SDK's EnrollBegin.

NameInTypeRequiredDescription
AuthorizationheaderstringyesBearer rh_sk_… secret key.
challengeIdbodystringyesThe challenge whose verify came back enrollmentRequired. Admission is judged under that challenge's policy.
ekPublicKeybodystringyesPEM/base64 EK public key produced by the client.
akPublicAreabodystringyesTCG-standard AK public area (TPM2B_PUBLIC), base64-encoded.
platformbodystringyesClient platform (e.g. "windows", "linux", "macos").
ekCertPembodystringnoPEM-encoded EK certificate. Omitted for firmware TPMs (Intel PTT / some AMD fTPM) with no NV-stored EK cert.
ekCertificateChainbodystring[]noPEM-encoded intermediate CA certs the device read from its own TPM NV; path-building material that must still terminate at a seeded root.

200 with the make-credential challenge on a validated EK chain + AK template; 422 admission_refused when the challenge's policy rejects this TPM class; 400 otherwise.

{
  "deviceId": "2f9c4a1c-8b…",
  "credentialBlob": "…",
  "encryptedSecret": "…"
}
POST/api/v1/attest/activate

Activate an enrolled device

Completes the activate-credential handshake: the client returns the decrypted make-credential secret, proving the AK is bound to the same TPM as the EK. Routed through PCP on Windows (raw activate is command-blocked).

NameInTypeRequiredDescription
AuthorizationheaderstringyesBearer rh_sk_… secret key.
deviceIdbodystringyesDevice id returned by /attest/enroll.
decryptedSecretbodystringyesThe decrypted make-credential secret.
akPublicKeybodystringnoAK public key; used to bind quote verification to the activated AK.

200 on a matching secret; 400 if activation fails.

{
  "deviceId": "2f9c4a1c-8b…",
  "status": "enrolled",
  "enrolledAt": "2026-06-18T18:09:11Z"
}

Admin: GET/POST /api/v1/admin/policies, GET/PATCH/DELETE /api/v1/admin/policies/{id} (Owner role). The request shape is on the Policies page.

Error codes

{ "error": "challenge_expired_or_used", "message": "…" }

Branch on error, never on message. A device that fails policy, a rejected class, or a ban is a 200 with device.verdict of fail or warn; the one policy outcome carried as a 4xx is admission_refused on the enroll relay, because there is no verdict yet. A failed key ask is a 200 with no key block.

errorHTTPMeaningWhat to do
invalid_secret_key401No valid rh_sk_ key in the Authorization header.Send `Authorization: Bearer rh_sk_…` from your backend only.
invalid_api_key401An enroll or activate relay arrived without a valid secret key.Same key, same header; these are server-to-server relays.
invalid_evidence400Malformed verify body: missing challengeId or evidence, or evidence with no device id.Pass the challengeId from /attest/challenge and the whole blob the client produced.
challenge_expired_or_used409Unknown, foreign, expired (about five minutes) or already-consumed challenge id.Mint a fresh challenge and re-run the client. Never reuse a challengeId.
unknown_policy422The policy named on the challenge or verify does not exist, was deleted, or belongs to another tenant. Nothing is substituted.Name a built-in, a policy you created, or omit `policy` for the project default.
policy_downgrade422Verify named a policy weaker than the challenge's.Name the policy on the challenge and omit it at verify, or pass the same or a stricter one.
admission_refused422The enroll relay ran admission under the challenge's policy and the class is not accepted. Refused before the credential is issued, so no elevation prompt is spent.Not retriable for this device and policy. Tell the user the device is not eligible, or mint the challenge under a policy that admits the class.
quota_exceeded429The plan's monthly active-device allowance is used up and this device is new this month. Already-active devices keep working.Upgrade the plan. The response carries the `device-limit-exceeded` quota header.
metering_unavailable500A billable route had no resolvable tenant, or usage metering failed. Fails closed rather than serving an unmetered attestation.Retry with backoff; if it persists, contact support with the request.

EK-chain and AK-template validation failures on /attest/enroll return a free-text { "error": "<reason>" } with 400: diagnostic strings for the enrolling host, not values to branch on. The native SDKs' own return values are a different layer, on the status codes page.

Transport failures and retries

TLS 1.2+ at rootherald.io. A handshake, reset or timeout surfaces from your HTTP client, not as a Root Herald body; there is no numeric transport-code space. Keep the CA store current and do not pin the leaf certificate.

  • Retry with exponential backoff: transport failures, metering_unavailable, and 429 honouring Retry-After.
  • Fix the request: invalid_secret_key, invalid_evidence, unknown_policy, policy_downgrade, free-text 400s.
  • Mint a fresh challenge: challenge_expired_or_used. Upgrade the plan: quota_exceeded. The same device under the same policy: admission_refused again.

Stability and deprecation

The base path is /api/v1/ and v1 is additive-only: new endpoints, new response fields and new optional request fields may ship at any time; a new required request field, a removed or renamed response field, or a change in a field's meaning waits for v2. Errors are stable string identifiers.

A deprecation is signalled in the response before anything is removed. Deprecated endpoints and fields carry the standard headers:

Deprecation: Tue, 17 Nov 2026 00:00:00 GMT
Sunset: Mon, 15 Feb 2027 00:00:00 GMT
Link: <https://rootherald.io/changelog/2026-11-deprecation>; rel="deprecation"

The SDKs warn once on stderr the first time they see one. Diff your SDK's wire version against /api/v1/openapi.json to catch drift.

Rate limits

Per-IP and per-session fixed windows return 429 with Retry-After. The SDKs back off automatically. There is no published throughput SLA; high-volume needs are a conversation.