Skip to main content

Credential

GET /v1/credential describes the credential presented on the request — as the DFOS issuance ledger records it. Who issued it, which application holds it, what it grants, how that application was resolved, and when it was issued and expires.

curl https://api.dfos.com/v1/credential \
-H 'Authorization: DFOS <request proof>' \
-H 'X-Credential: <credential>'
{
"subjectDid": "did:dfos:6encc4akrze2ah9kntzd9tc8zr24crc",
"clientDid": "did:dfos:79h6z77had2kc68ffdkhac9e4tv3rf",
"scopes": ["read:profile", "read:memberships"],
"tier": "jit",
"domain": "example.com",
"issuedAt": "2026-08-01T14:02:00.000Z",
"expiresAt": "2026-10-30T14:02:00.000Z"
}

It needs no particular scope

This route is gated like every other credential route — a live credential and a fresh request proof — but it requires no scope at all. A credential may always describe itself.

That is the useful shape. Your app holds the artifact already and can decode most of it locally; what it cannot learn from the bytes is what DFOS's ledger says about it — whether the grant still stands, when it was actually issued, which tier resolved your application. Making that answer conditional on a scope would have made the one call an app wants at startup the one call it might not be allowed to make.

The gate is still a gate. A revoked or expired credential does not get to describe itself as revoked; it is refused with 403, the same as anywhere else. Which makes this a clean liveness check:

const res = await fetch('https://api.dfos.com/v1/credential', { headers });
if (res.status === 403) return promptUserToReconnect();
const { scopes } = await res.json();
if (!scopes.includes('read:memberships')) return promptUserForMoreAccess();

It describes the presented credential, and only that

There is no path parameter and no way to ask about somebody else's grant. The subject of the answer is the credential that authenticated the request — so a successful response is never a disclosure: everything in it is a fact about an artifact you already hold.

A null domain means a local application

domain is the bare hostname the grant was issued to. It is null on the loopback tier, and that is meaningful rather than missing data.

tierWhat it meansdomain
jitResolved live from the application's /.well-known/dfos-app.json. Every hosted app is this.present
loopbackThe key-proven local tier: a client on someone's machine that proved it holds the audience key.null

A loopback client — a CLI, an agent, a script — proved a key, not an origin. There is no hostname that would be true to show, so DFOS shows none rather than inventing one. When domain is null, identify the holder by clientDid.

tier is an open enum like every enum on this API: treat an unrecognized value as an opaque string.

Fields

FieldNotes
subjectDidProtocol DID of the user who issued the credential — the subject every gated route serves.
clientDidProtocol DID of the application the credential was issued to. Canonical when domain is null.
scopesThe actions this grant carries, in DFOS's canonical order. Authoritative — a route 403s when its action is absent.
tierHow the application was resolved at consent time. See the table above.
domainHostname the grant was issued to, or null for a local application.
issuedAtWhen the user approved the grant (ISO 8601 UTC).
expiresAtWhen the grant lapses on its own (ISO 8601 UTC).

expiresAt is the backstop, not the lever. A user revoking a grant ends it on your app's very next request, long before this date — see Scopes and credentials.

Calling it

The request carries the same two headers as every gated route, and the mechanics are the same: see Authentication.

  • 401 — your proof was wrong. Sign a fresh one and retry.
  • 403 — the credential is revoked, expired, or not one DFOS issued. Retrying will not help; ask the user for a new grant.
  • 503 — DFOS could not complete verification. Retry with backoff.

Caching and privacy

Cache-Control: no-store, never shared-cached. The response names a specific user and a specific grant.