Profile
GET /v1/profile reads the profile of the user who granted your app access —
one of this API's credential-gated routes, alongside the
membership routes and
GET /v1/credential. The credential comes from
Sign in with DFOS, where the user
approves a read:profile and/or read:email grant.
curl https://api.dfos.com/v1/profile \
-H 'Authorization: DFOS <request-proof>' \
-H 'X-Credential: <credential>'
Under a credential covering read:profile read:email:
{
"did": "did:dfos:z8zt7ecn9h8n782kae3k796crva2c73",
"username": "bvalosek",
"displayName": "Brandon",
"description": "cofounder / architect @ DFOS and Metalabel . building what i want to see more of ",
"avatarUrl": "https://dfos.imgix.net/media/public/ekceek9z64vz4cavrzr7v2-lil-robo-pfp.png",
"email": "bvalosek@example.com",
"createdAt": "2025-11-19T21:07:44.512Z"
}
Which scopes open which fields
Either scope opens the route, and each serves its own fields. Only did is
unconditional. "Profile fields" means username, displayName, description,
avatarUrl, and createdAt.
| Your credential carries | Route | Profile fields | email |
|---|---|---|---|
read:profile | 200 | present | absent |
read:email | 200 | absent | present |
| both | 200 | present | present |
| neither | 403 | — | — |
Under read:email alone the whole response is
{"did": "did:dfos:z8zt7ecn9h8n782kae3k796crva2c73", "email": "bvalosek@example.com"}.
The scope decides the shape and nothing else does: a user's privacy toggle never
changes what your credential returns. There is no path parameter — the
credential selects the subject.
Fields
| Field | Served under | Notes |
|---|---|---|
did | always | The user's protocol DID. Canonical and stable — store this. |
username | read:profile | Their handle, or null if unset. A mutable alias; never key on it. |
displayName | read:profile | Display name, or null. |
description | read:profile | Profile bio, or null. |
avatarUrl | read:profile | Permanent public CDN URL for their avatar, or null if they have none. |
createdAt | read:profile | When they joined DFOS (ISO 8601 UTC). |
email | read:email | The account email — available nowhere else on this API. |
A field your grant does not cover is absent, never null. null means the
user has none, so check for the key, not for a falsy value.
avatarUrl is an unsigned, non-expiring CDN link — the same one
GET /v1/users/{user} serves. No other media appears here.
Calling it
The request carries the credential and a fresh request proof signed over this exact method, host, path, and (empty) body — see Authentication, Scopes and credentials, and Set up § 7.
Reading your OWN profile needs no credential: sign an identity proof with one of
your own keys and send Authorization alone, per
Reading your own data needs no credential.
That call returns every field above. An identity proof presented together
with X-Credential is refused 401 with "an identity proof cannot be
presented alongside X-Credential"; send one or the other.
| Status | Meaning |
|---|---|
401 | Your proof was wrong. Sign a fresh one and retry. |
403 | The credential was revoked, expired, or carries neither scope for this host. Ask the user for a new grant; retrying will not help. Revocation takes effect on the next request, so this is how a user withdrawing access reaches your app. Missing only ONE of the two scopes is not a 403 — those fields are just absent. |
413 | The request body exceeded the maximum this endpoint will authenticate. |
429 | The per-grant budget is exhausted; the body names the scope and a retryAfterMs. See Rate limits and caching. |
503 | DFOS could not complete verification. Retry with backoff. |
Caching and privacy
Cache-Control: no-store, never shared-cached; don't put this response behind a
shared cache or CDN of your own either. Revoking ends your access to the account
email but not your copy of it, so store only what your app needs, delete it when
the grant ends, and ask for read:email only when you need it.