---
title: "Update Me"
method: PATCH
path: "/users/me"
tags: ["users"]
---

# Update Me

`PATCH /users/me`

## Request

- Base URL: `https://app.zeno-works.ch` (the document declares no server; this is the origin it was published from)
- URL: `https://app.zeno-works.ch/users/me`
- Auth: none declared

Self-service profile edit (jwt) — the caller's own name/phone/email/UI language only.

Declared beside ``/me`` (before ``/{user_id}``) per convention. ``User`` is GLOBAL,
so the self-write via ``ScopedDep`` needs no tenant elevation. Privileged fields are
not in :class:`MeUpdate`, so a user can never escalate *within this request*.

Returns the **same envelope** ``GET /users/me`` does — one path, one answer to "who
am I" (:func:`_me_response`).

**Refused inside a support session**, locked or unlocked. Editing your own account is
a platform act, not part of a tenant visit — the same distinction
:func:`refuse_credential_ops_in_support_session` draws for key issuance, and it is
keyed on the session existing at all for the same reason: unlocking authorises a
write to *this customer's books*, never a write to the platform's own identity rows.
``User`` is **GLOBAL**, so no ring filters it and this router gate is the only
authorization there is — :func:`deny_company_scoped_key` below cannot serve, because
it keys on the API-key header and an operator inside a session holds a browser
session. Left open, the visit's audit trail asserted a confinement to one tenant
while the operator rewrote a row belonging to none of them, and the email half is the
takeover vector the next paragraph describes. Ring 3 refused the *locked* half as an
ordinary write, with a message about the customer's books that this write is not.

**Refused for a company-scoped API key** (ZET-176). The absent privileged fields
are not the whole story: ``email`` IS the account's login identity, so writing it
escalates out of band — change the owner's email, ask ``/auth/request-reset`` for
an OTP at the new address, reset the password, log in. A credential confined to
one company would come out the other side holding the owner's full session,
``is_admin`` included, and every gate this ticket added would be moot. Same
privilege-persistence reasoning as :func:`require_session_user` on key
management, one route over.

## Headers

- `authorization` string, nullable

## Cookies

- `access_token` string, nullable

## Request body

- MeUpdate: ``PATCH /users/me`` body — self-service profile edit (partial). Only the caller's own **profile** fields; privileged fields (``is_admin``, ``is_active``, ``companies``, ``memberships``, ``password``) are deliberately absent — the self endpoint must never let a user escalate. Password changes go through ``POST /users/me/change-password``.
  - `first_name` string, nullable
  - `last_name` string, nullable
  - `email` string, email, nullable
  - `phone` string, nullable
  - `ui_language` 'de' | 'en' | 'fr' | 'it', nullable

## Response `200`

Successful Response

- MeResponse: ``GET``/``PATCH /users/me`` → the caller, **and why the answer is narrowed**. A superset of :class:`UserResponse` (``user`` is byte-identical), so an existing client is unaffected. The extra field exists because ``user`` alone cannot be read correctly inside a support session — see :class:`SupportSessionContext`. ``support_session`` is **required-but-nullable**, never optional: the same rule :attr:`Capability.operation` states. A client must be able to tell "not in a session" (``null``) from "this server predates the field" (absent), and an absent field in the generated client types is an ``undefined`` no consumer guards.
  - `user` UserOut, required: Serialized user (no secrets). ``companies`` is an array of company ids; ``memberships`` adds the per-membership role.
    - `id` string, required
    - `first_name` string, required
    - `last_name` string, required
    - `email` string, email, required
    - `phone` string, nullable
    - `is_admin` boolean, required
    - `is_active` boolean, required
    - `ui_language` string, nullable
    - `companies` string[]
    - `memberships` MembershipOut[]
      - `company_id` string, required
      - `role` 'owner' | 'accountant' | 'member', required
  - `support_session` SupportSessionContext, required: Why a ``/users/me`` answer is narrowed: the request is inside a support session. Present **only** on the ``/users/me`` envelope, because ``/users/me`` is the one endpoint whose narrowing is otherwise indistinguishable from a fact about the account. Inside a session the caller's ``companies`` and ``memberships`` come back empty and ``is_admin`` comes back ``False`` — which is byte-identical to the answer a brand-new user with no memberships gets. Those two states demand opposite behaviour from a client (the new user must be offered onboarding; the confined operator must not be offered a cross-tenant create), so a client that cannot tell them apart will get one of them wrong. This field is how it tells them apart. It discloses nothing: the caller named this session themselves, in the ``X-Support-Session`` header of the request being answered.
    - `company_id` string, required
    - `may_write` boolean, required

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/zeno-works/apis/zeno-api.md) · [All operations](https://skmtc.dev/zeno-works/apis/zeno-api/llms.txt) · [OpenAPI document](https://skmtc.dev/zeno-works/apis/zeno-api/revisions/1eb93dde146c?raw)
