Profiles

See Domain Model → Profiles for the global-vs-per-app model this resource implements.

  1. Endpoints
  2. GET /users/{id}/profile
  3. PATCH /users/{id}/profile
  4. Avatar uploads
    1. POST /users/{id}/avatar/uploads
    2. POST /users/{id}/avatar/uploads/{uploadId}/complete
    3. DELETE /users/{id}/avatar
  5. GET /users/{id}/apps/{appId}/profile
  6. PATCH /users/{id}/apps/{appId}/profile
  7. 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_type is image/png, image/jpeg, or image/webp. content_length is the file’s size in bytes, at most 2 MB (2,097,152). Anything else is 422 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_name is read-only: display_handle if it’s set, otherwise the global Profile’s display_name. It saves every app from re-implementing that fallback.
  • No record yet? You get the default: display_handle: null, custom: {}, and updated_at: null. Nothing is written on a read; the row is created by the first PATCH. That keeps GET safe, 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: platform users.list (held by the support Role) can read regardless, and users.manage can 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_handle is 1–64 characters, or null to 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.
  • custom follows Conventions → Partial updates: keys are merged one level deep, and a key sent as null is removed. The serialized size limit is 16 KB.
  • custom isn’t validated against a schema, unlike AppSettings. See AppProfile for why. All of an AppProfile is treated as personal data: the erasure cascade sets display_handle to null and custom to {}.
  • A self-service or app-key write produces no Audit Event. A users.manage write to someone else’s AppProfile writes profile.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.