LifeCare.ID® Developer Docs Public

API reference ✅

The complete endpoint catalogue available today. This page is a human-readable companion to the machine-readable contract — the authoritative, drift-tested source is docs/contracts/openapi.json (regenerated on every API change and enforced by tests). Import that file into Postman / Swagger / your codegen.


Identity (L1) ✅

Method Path Purpose
POST /v1/identity/register Register a P-256 public key → derive DID, return DID document
POST /v1/identity/register/begin WebAuthn registration: creation options
POST /v1/identity/register/finish Verify attestation → DID + session
POST /v1/identity/auth/begin WebAuthn sign-in: request options
POST /v1/identity/auth/finish Verify assertion → session
GET /v1/identity/resolve/{did} Resolve a DID document
GET /u/{user_id}/did.json Standard did:web resolution path
GET /agent/{agent_id}/did.json Reserved agent namespace; always 404 agent identities are not yet supported
POST /v1/identity/rotate Trust-partner-approved key rotation (header X-Trust-Partner-Token; fail-secure). Also ends every native app connection (refresh-token family) of the DID
POST /v1/identity/session/verify Resolve a session token → {active, did, expires_at}
POST /v1/identity/session/logout Revoke a session token (and, for a session a native app's refresh token minted, that whole connection)
POST /v1/identity/pairing/exchange A native app adopts the identity a signed-in client paired to it: single-use pairing token → session. Naming a registered native client_id also returns a rotating refresh_token (Authentication)
POST /v1/identity/refresh/admin/revoke Operator: end a member's native app connections (all, or one client_id's) and the sessions they minted (header X-Trust-Partner-Token; fail-secure)

Register example:

POST {base}/v1/identity/register
{ "public_key_jwk": { "kty": "EC", "crv": "P-256", "x": "…", "y": "…" },
  "terms_version": "2026-09-07", "age_confirmed": true }
→ { "did": "did:web:…", "created": true, "did_document": { … } }

DID derivation: did:web:<domain>:u:<first-16-hex-of-RFC7638-thumbprint>. Registration is idempotent by key (created:false if already known).

Terms acceptance (contract 0.13.0). Creating an identity requires the person's acceptance of the member terms and privacy notice: terms_version (a version string the service knows, currently 2026-09-07) and age_confirmed (they are 18 or older). Both are stored against the identity; a missing, unknown, or negative acceptance is 422 and creates nothing. POST /v1/identity/register/finish takes the same two fields, optional in the schema and required whenever the ceremony would create a new identity — an existing DID re-binding a passkey keeps the acceptance it already has. Identities created before this landed carry no acceptance, and nothing backfills them.

Reserved namespaces. u: is the only principal kind served today. Two more are reserved so a third principal type never has to be retrofitted into token and ledger schemas: did:web:<domain>:node:<id> (a node's own identity) and did:web:<domain>:agent:<id> (a personal agent, see Agent trust), the latter resolving at /agent/{id}/did.json. Neither can be registered or resolved yet; both answer 404 with "<kind> identities are not yet supported", so a caller gets a straight answer rather than a guess.


Recovery (L1) ✅

Email-magic-link recovery (beta trust anchor). Primarily an end-user / web-surface flow; listed for completeness.

Method Path Purpose
POST /v1/recovery/email Enroll a recovery email (session-auth) → verify link
POST /v1/recovery/email/verify Confirm the email from the link
POST /v1/recovery/email/status Masked email + verified flag (session-auth)
POST /v1/recovery/begin Start recovery from an email → recovery link
POST /v1/recovery/finish New passkey + recovery token → rotate key, new session

Method Path Purpose
POST /v1/consent/grant Create a grant → {consent_id, receipt}. Optional proof — a WebAuthn assertion over the grant digest, verified whenever present
POST /v1/consent/revoke Revoke a grant → kills tokens, ledgers it
GET /v1/consent/grants/{did} All grants for a DID (receipts). With caller authentication required: the person or a first-party client
GET /v1/consent/ledger/{did} Full hash-chained ledger {chain_valid, entries[]}. With caller authentication required: the person alone
POST /v1/access/record Record a data access (resource-host audit). Names the reader with actor_did (preferred, must be a DID) or the legacy free-text accessor; one is required. A signer's DID must match the actor_did it asserts (403)

See Scopes & consent for field-level detail, the receipt shape, and how a device proof is constructed. Scopes: cgm, daily, labs, body_composition, recovery, activity, insights, told_to_guardian. A grant naming a grantee in LIFECAREID_MEMBER_ONLY_GRANTEES (the person's guardian) is accepted only from the person themselves, never from a client credential (details).

Caller authentication (the four write endpoints and the two reads). /v1/consent/grant, /v1/consent/revoke, /v1/access/record, /v1/token, /v1/consent/grants/{did} and /v1/consent/ledger/{did} accept a holder signature (X-LifeCare-Signature, the scheme under Resource registry below), confidential-client credentials (Authorization: Basic, or client_id + client_secret in the body), or the person's own session (Authorization: Bearer lcs_...). A signature or client credential you present is always verified; whether an unauthenticated call is accepted, and who may act for whom, is a deployment setting (LIFECAREID_REQUIRE_CALLER_AUTH, default off). See Authenticating your calls for the rules and the rollout.


Authorization / tokens (L3) ✅

Method Path Purpose
POST /v1/token Issue a data-access token (RPT) against a matching grant. Optional purpose_dpv matches the grant on its DPV term instead of exact text. An authenticated caller's requester must be its own identity — client id or signer DID (403)
POST /v1/token/introspect Validate a token → claims or {active:false}. Requires your client credentials (HTTP Basic, or client_id + client_secret in the body; RFC 7662 §2.1): no credential 401, a session or holder signature 403, both before the token is looked up. token_type:"access" carries resource_owner + scope; token_type:"user" carries subject_did. A third shape, token_type:"delegated", is published and never issued: fail closed on any type you do not implement
GET /v1/revocation/{user_id} Public revocation registry for a subject

See Authorization & tokens.


Method Path Purpose
GET /v1/panel/summary Identity + active grants
GET /v1/panel/feed Reverse-chron ledger feed; ?category=&limit=&before=
POST /v1/panel/grant Grant as the session user (no subject_did)
POST /v1/panel/revoke Revoke (ownership-checked)
GET /v1/panel/resources The user's current ownership-and-location map (L2b, below)

See Embedding the consent panel.


Resource registry (L2b) ✅

What exists about the user, who holds it, where — the counterpart to consent's who may read it. Written by holders (the user's own node, a holder the user granted, the user's own device), read by the user, and by each holder for its own entries only.

Method Path Purpose
POST /v1/registry/record A holder registers / versions / tiers / moves / shreds / deletes one resource it holds. Idempotent on (subject_did, holder.id, resource_id, event, manifest.content_hash); a replay returns the existing entry_hash. Who may write what: see below
GET /v1/registry/{did} Chained history, newest first; ?limit=&before=<entry_hash>; returns chain_valid for the page. The user reads all of it, a signed holder only its own entries (see below)
GET /v1/panel/resources The user's current map grouped by holder (session token), with erased per holder

The entry. subject_did, resource_id, kind (raw_at_source|derived|twin_artifact|copy), category (the strict ResourceCategory vocabulary in the contract), holder{id, role}, location{kind, ref}, event (registered|versioned|tiered|moved|shredded|deleted), optional consent_id, optional manifest{version, content_hash, recipe} (never for raw_at_source), optional supersedes. Status is derived from the event, never sent.

Zero-PHI at the boundary. Every field is an enum, a regex-typed identifier, a sha256 hash, a version token or a registry timestamp; there is no free-text and no numeric field anywhere on the entry. Unknown JSON keys are rejected (422), not dropped, and unknown categories are rejected — the vocabulary grows by contract bump, never by payload.

Who writes. POST /v1/registry/record admits its parties alone, in every deployment:

Caller Writes
the user's own node: a client named in LIFECAREID_FIRST_PARTY_CLIENTS, with its client credential (HTTP Basic) its own entries (holder.id is its client id), in any holder.role but user_device, and source entries (holder.role: source, a provider id such as withings as holder.id, never a DID), for any user
a holder, signing with X-LifeCare-Signature (below) only entries whose holder.id is the DID that signed, in any holder.role but home_node and user_device, and only for a user who has an active grant (not revoked, not expired) naming that DID as grantee.id
the user (their session, Bearer lcs_..., or a signature by their own DID) only their own user_device entries (holder.id their DID)
anyone else nothing: 401 with no credential, 403 for any other caller, both before the DID is looked up and before anything is written or ledgered

A holder that writes for a user asks that user for a grant first; the grant's scope does not matter, its grantee does. An admitted write for a subject LifeCare.ID does not know is 404 "unknown subject DID".

Who may claim each role. The user's ledger reads each holder.role as a sentence ("Your home node now keeps ...", "Your device ..."), so two roles belong to the user's own things: only the user's own node writes home_node, and only the user's own device writes user_device.

holder.role the user's own node a holder the user granted the user's own device
source yes: its own entries, and source entries naming a provider yes no
home_node yes no no
replica yes yes no
user_device no no yes
ehr yes yes no
external_holder yes yes no

A role the caller may not claim is 403, with a detail that names the rule (for example "only the member's own node (a first-party client) may write home_node entries ..."), before the DID is looked up and before anything is written or ledgered. A holder writes in the role that says what it is: a replica it keeps, an ehr that is a practice's system of record, an external_holder outside the network, or a source.

Who reads the history. GET /v1/registry/{did} answers its parties alone, in every deployment:

Caller Reads read_as
the user (their session, Bearer lcs_..., or a signature by their own DID) every entry subject
a holder, signing with X-LifeCare-Signature (below) only the entries whose holder_id is the DID that signed; an empty page if it holds nothing for this user, whether or not the DID exists holder
anyone else nothing: 401 with no credential, 403 for any other caller (another user's session, any client), both before the DID is looked up

A holder signs the GET like a write, over an empty body. Its before cursor must be one of its own entries' entry_hash. Its chain_valid checks each entry it is shown against that entry's real predecessor in the user's chain, so it means what it means in the user's view: every entry shown sits, unaltered, at its place in the chain. Each entry keeps its real prev_hash, which may be the hash of another holder's entry; the entry itself is never shown.

Erasure proof. A holder completes an erasure by writing shredded for each of its entries. The panel's erased flag is computed from the registry — a holder is erased for a user exactly when none of its entries is active or tiered.

Holder signing. Both /v1/registry/record and /v1/access/record accept:

X-LifeCare-Signature: keyid="<holder DID>",alg="ES256",created=<unix>,sig="<base64url r||s>"

signed over METHOD\npath\ncreated\nsha256(body) (UTF-8, newline-separated) and verified against the key LifeCare.ID publishes for keyid. created must be within 300 s. A presented signature is always verified. On /v1/access/record, whether an unsigned holder write is accepted is a deployment setting (LIFECAREID_REQUIRE_HOLDER_SIGNATURE, default off). The registry write does not read that setting: who may write there is the table above, in every deployment.


Federation — "Sign in with LifeCare.ID" ✅

Browser-redirect + form handshake on the LifeCare.ID origin. Not in the JSON contract (it's a redirect/form surface), but stable and documented in Authentication.

Method Path Purpose
GET /authorize OAuth authorization request (code + PKCE S256)
POST /token Code exchange → {did, access_token} (a native public client also receives refresh_token); grant_type=refresh_token renews a native app's session and rotates its refresh token
POST /revoke RFC 7009 revocation: a native app's sign-out ends its connection
GET /, /signin, /recover, /verify-email LifeCare.ID web surface pages
POST /session, /session/handoff, /session/establish Set the session cookie on the LifeCare.ID origin (__Host-lc_id_session on https). Both the federation form-POST and the in-place /session take a one-time handoff code bound to the browser, never a session token; the form returns only to a same-origin path
POST /session/logout, GET /logout End a session (RP-initiated logout honors registered origins)

Service

Method Path Purpose
GET /healthz Liveness probe → {status:"ok", service:"lifecareid"}
POST /v1/notify/operator The user's own node reports an operational event (a new error fingerprint, an outbound failure) to the operator's fixed alert address: {subject, text}, operational text only, never health data. Only a client named in LIFECAREID_FIRST_PARTY_CLIENTS, with its client credential (HTTP Basic): 401 with no credential, 403 for any other caller, before anything is sent, in every deployment. {sent:false, reason:"no_operator_email"} where no address is configured. Not a route for apps
GET /healthz/deep Whether the way out works → 200 {ok:true, checks:{mail_relay:"ok"}} or 503 {ok:false, checks:{mail_relay:"fail"}}. The mail relay resolves and answers (no secret, no mail sent); runs at most once a minute, answers within about four seconds, Cache-Control: no-store, no host or error text in the body

Conventions