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 platform users.list |
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 currently has active access to that Application (see the access gate below). 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" }
| 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 |
Read-only here (422 read_only_field). It mirrors the global Settings locale; change it with PATCH /v1/users/{id}/settings. |
// Response — 200, the full updated Profile
{
"user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
"display_name": "Jordan A.",
"avatar_url": "https://assets.substratalapps.com/avatars/usr_01JAG3.png",
"contact_email": "jordan@example.com",
"contact_phone": "+13035550142",
"locale": "en-US",
"updated_at": "2026-10-05T10:00:00Z"
}
A failed validation lists every field at once:
// PATCH { "display_name": "", "contact_phone": "303-555-0142", "avatar_url": "http://example.com/a.png" }
{
"error": {
"code": "validation_failed",
"message": "3 fields failed validation.",
"details": {
"fields": [
{ "field": "display_name", "code": "too_short", "min": 1 },
{ "field": "contact_phone", "code": "invalid_format", "pattern": "E.164" },
{ "field": "avatar_url", "code": "invalid_format", "allowed": ["https"] }
]
}
}
}
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. Support can bypass the gate for investigations: platformusers.list(held by thesupportRole) can read regardless, andusers.managecan read and write regardless.
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. All of an AppProfile is treated as personal data: the erasure cascade setsdisplay_handletonullandcustomto{}.- A self-service or app-key write produces no Audit Event. A
users.managewrite to someone else’s AppProfile writesprofile.updated.
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. |
read_only_field |
422 | A Profile PATCH included locale or user_id, or an AppProfile PATCH included effective_display_name. |
validation_failed |
422 | A field breaks the rules above; see the example under PATCH /v1/users/{id}/profile. |