Profiles
See Domain Model → Profiles for the global-vs-per-app model this resource implements.
- Endpoints
GET /users/{id}/profilePATCH /users/{id}/profile- Avatar uploads
GET /users/{id}/apps/{appId}/profilePATCH /users/{id}/apps/{appId}/profile- Errors specific to this resource
Endpoints
| Method | Path | Requires | Purpose |
|---|---|---|---|
GET |
/users/{id}/profile |
self, users.list, or app-confined users.list |
Fetch global Profile. |
PATCH |
/users/{id}/profile |
self or users.manage |
Update global Profile. |
POST |
/users/{id}/avatar/uploads |
self or users.manage |
Start an avatar upload. |
POST |
/users/{id}/avatar/uploads/{uploadId}/complete |
self or users.manage |
Check the uploaded image and make it the avatar. |
DELETE |
/users/{id}/avatar |
self or users.manage |
Remove the avatar. |
GET |
/users/{id}/apps/{appId}/profile |
self, the app’s own key, or platform users.list |
Fetch per-app AppProfile. |
PATCH |
/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 /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 /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. To host the image on the platform, upload it instead; see Avatar uploads. Any other https URL is stored as given, but first-party clients only display images from assets.substratalapps.com. Moving off a hosted avatar deletes that image. |
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 /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.
Avatar uploads
The platform hosts avatars at https://assets.substratalapps.com, the only image origin first-party clients allow under their Content Security Policy. The client uploads the file straight to storage, so it never passes through the API, and then asks the API to check it.
POST /users/{id}/avatar/uploads
// Request
{ "content_type": "image/png", "content_length": 48213 }
// Response — 201
{
"id": "upl_01JAH6ZQ3W4E5R6T7Y8U9I0O1P",
"url": "https://substratal-prod-assets-196936048600.s3.us-east-1.amazonaws.com/",
"fields": {
"key": "uploads/avatars/usr_01JAG3Z9X8QS3F6K2M4N5P6R7S/upl_01JAH6ZQ3W4E5R6T7Y8U9I0O1P",
"Content-Type": "image/png",
"policy": "eyJjb25kaXRpb25zIjpb…",
"x-amz-algorithm": "AWS4-HMAC-SHA256",
"x-amz-credential": "…",
"x-amz-date": "20261027T101500Z",
"x-amz-signature": "…"
},
"content_type": "image/png",
"max_bytes": 2097152,
"expires_at": "2026-10-27T10:20:00Z"
}
Then POST a multipart/form-data form to url: every entry of fields, unchanged, then the file as the last field, named file, before expires_at (5 minutes). Storage refuses any other Content-Type, an empty file, or one over max_bytes.
const form = new FormData();
for (const [k, v] of Object.entries(upload.fields)) form.append(k, v);
form.append("file", file); // last
await fetch(upload.url, { method: "POST", body: form }); // 204
content_typeisimage/png,image/jpeg, orimage/webp.content_lengthis the file’s size in bytes, at most 2 MB (2,097,152). Anything else is422 validation_failed.- Limited to 10 per caller per hour, shared with Application icon uploads.
POST /users/{id}/avatar/uploads/{uploadId}/complete
No body. The API reads the uploaded file and checks it:
- its magic bytes are the declared
content_type; - it’s no larger than 2 MB;
- its dimensions, read from the header only, are at most 2048 × 2048.
A file that passes is stored at a content-addressed URL, https://assets.substratalapps.com/avatars/<user_id>/<hash>.<ext>, which never changes, so clients and the CDN may cache it for a year. It becomes avatar_url, and the previous hosted avatar, if any, is deleted. The response is 200 with the updated Profile, as for PATCH.
A file that fails a check is deleted, and the response is 422 invalid_image with details.reason:
reason |
Meaning |
|---|---|
empty |
The file has no bytes. |
too_large |
Over 2 MB (details.max_bytes). |
unsupported_type |
Not a PNG, JPEG, or WebP image, whatever was declared. |
type_mismatch |
A PNG, JPEG, or WebP, but not the declared type. |
unreadable |
The image header can’t be read. |
dimensions_too_large |
Wider or taller than 2048 pixels (details.width, details.height). |
An upload that was never sent, has already been completed, or has expired (unconfirmed uploads are deleted after a day) is 404 upload_not_found; start a new one. An upload belongs to the User it was started for: completing it under another {id} is 404.
DELETE /users/{id}/avatar
Sets avatar_url to null and deletes the hosted image, if it is one; an external URL is just cleared. 204. A restricted agent key can’t call it.
A self-service change writes no Audit Event. Completing an upload or deleting the avatar for someone else writes profile.avatar_changed, with avatar_url in before and after.
The export includes the Profile and so avatar_url. Erasure deletes the hosted image along with the Profile. A test-mode User’s images are kept under a separate test/ prefix and deleted with the User by the 30-day test-data purge. See NFR → Image uploads.
GET /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 /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. |
invalid_image |
422 | An uploaded avatar failed a check; details.reason says which. |
upload_not_found |
404 | Completing an upload that was never sent, was already completed, or has expired. |
service_unavailable |
503 | Image uploads aren’t configured in this environment. |
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 /users/{id}/profile. |