Profiles
See Domain Model → Profiles for the global-vs-per-app model this resource implements.
- Endpoints
GET /v1/users/{id}/profilePATCH /v1/users/{id}/profileGET /v1/users/{id}/apps/{appId}/profilePATCH /v1/users/{id}/apps/{appId}/profile- Errors specific to this resource
Endpoints
| Method | Path | Requires | Purpose |
|---|---|---|---|
GET |
/v1/users/{id}/profile |
self, users.list, or app-confined users.list |
Fetch global Profile. |
PATCH |
/v1/users/{id}/profile |
self or users.manage |
Update global Profile. |
GET |
/v1/users/{id}/apps/{appId}/profile |
self, the app’s own key, or users.manage |
Fetch per-app AppProfile. |
PATCH |
/v1/users/{id}/apps/{appId}/profile |
self, the app’s own key, or users.manage |
Update per-app AppProfile. |
“The app’s own key” means an app-scoped API Key whose scope is {appId}. Any app-scoped key can read and write AppProfile for its own Application, with no extra permission, as long as the user has an access path to that Application. It can never reach another Application’s AppProfile (404).
GET /v1/users/{id}/profile
// Response — 200
{
"user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"display_name": "Jordan Alvarez",
"avatar_url": "https://assets.substratalapps.com/avatars/usr_01JAG3.png",
"contact_email": "jordan@example.com",
"contact_phone": null,
"locale": "en-US",
"updated_at": "2026-09-20T14:12:00Z"
}
An app-scoped key with users.list can read (never write) the global Profile of users who have an access path to its Application, which is how an app gets a user’s name and avatar.
PATCH /v1/users/{id}/profile
// Request — only the fields being changed
{ "display_name": "Jordan A.", "contact_phone": "+13035550142" }
// Response — 200, full updated object
| Field | Validation |
|---|---|
display_name |
1–100 characters. Required on the record, so it can’t be set to null. |
avatar_url |
https URL, ≤ 2,048 characters, or null. The API stores the URL; it doesn’t host or proxy images. |
contact_email |
Valid email, or null. Not verified, and not used for login. |
contact_phone |
E.164 (+ and 8–15 digits), or null. |
locale |
BCP 47 tag, for example en-US or fr-CA. Must be a tag the API recognizes; otherwise 422. |
A self-service change writes no Audit Event. A change made by an admin to someone else’s Profile writes profile.updated — see Orders & Audit.
GET /v1/users/{id}/apps/{appId}/profile
// Response — 200
{
"user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"application_id": "app_timetrack",
"display_handle": "j.alvarez",
"effective_display_name": "j.alvarez",
"custom": { "department": "Engineering" },
"updated_at": "2026-09-20T14:15:00Z"
}
effective_display_nameis read-only:display_handleif it’s set, otherwise the global Profile’sdisplay_name. It saves every app from re-implementing that fallback.- No record yet? You get the default:
display_handle: null,custom: {}, andupdated_at: null. Nothing is written on a read; the row is created by the firstPATCH. That keepsGETsafe, and still means no app ever has a separate “provision this user” step. - Access gate. For the user themselves and for the app’s own key, the user must currently have active access to the Application. Otherwise the response is
403 entitlement_required, so a disabled or never-purchased app doesn’t accumulate profile data.users.managecallers (support) can read and write regardless, for investigations.
PATCH /v1/users/{id}/apps/{appId}/profile
// Request — add a key, remove a key
{ "custom": { "seat_number": 14, "department": null } }
// Response — 200, full updated object
{
"user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"application_id": "app_timetrack",
"display_handle": "j.alvarez",
"effective_display_name": "j.alvarez",
"custom": { "seat_number": 14 },
"updated_at": "2026-10-05T10:02:00Z"
}
display_handleis 1–64 characters, ornullto fall back to the global name. It isn’t unique, because the API doesn’t enforce uniqueness of app-local handles. An Application that needs unique handles enforces that itself.customfollows Conventions → Partial updates: keys are merged one level deep, and a key sent asnullis removed. The serialized size limit is 16 KB.customisn’t validated against a schema, unlike AppSettings. See AppProfile for why. It’s treated as personal data in its entirety: the erasure cascade clears it completely.
Errors specific to this resource
| Code | Status | When |
|---|---|---|
entitlement_required |
403 | Self or app-key access to an AppProfile for a user without active access to that Application. |
user_not_found |
404 | {id} doesn’t resolve, or isn’t visible to an app-confined caller. |
application_not_found |
404 | {appId} doesn’t resolve, or isn’t the calling app key’s own Application. |