{"openapi":"3.0.0","info":{"title":"Purple Identity API","description":"Purple Identity — the canonical people directory for a Purple Suite instance. Every other app (HRIS, Calendar, Expense, ITSM, …) draws its internal people from this same roster, so an email means the same person everywhere. Exposes an OData-queryable /people collection AND an emulation of the Moveworks Identity Gateway (GET /users, GET /users/{userId}) so a deployment can sync its user directory directly from Purple Suite.\n\n---\n\n**Acting as a user (`X-PS-Impersonate-User`).** Every endpoint accepts an optional `X-PS-Impersonate-User` request header whose value is the email of the instance user to act as. The effective user resolves in this order: the header if present, else the instance's configured default MCP user, else none. User-scoped paths additionally accept the literal `me` in place of an email, and Graph-style apps also serve `/me/…` twins of their `/users/{email}/…` paths that resolve the same way. This header is honored by all operations but is intentionally omitted from each operation's parameter list so it is not surfaced as a per-tool argument by MCP clients; see the `ImpersonateUser` entry under `components.parameters`.","version":"1.0.0"},"servers":[{"url":"https://marketplace.moveworks.com/api/purple-suite/identity"}],"security":[{"patAuth":[]}],"components":{"securitySchemes":{"patAuth":{"type":"http","scheme":"bearer"}},"parameters":{"InstanceId":{"in":"header","name":"X-Instance-ID","required":true,"schema":{"type":"string"}},"ImpersonateUser":{"in":"header","name":"X-PS-Impersonate-User","required":false,"schema":{"type":"string","format":"email"},"description":"Optional. Act as a specific user in the target instance — the value is that user's email (must exist in the instance). Resolution order for the effective user: (1) this header, if set; (2) the instance's configured default MCP user; (3) otherwise no user is bound. User-scoped paths (e.g. /users/{email}/…) also accept the literal \"me\", and Graph-style apps serve /me/… twins of those paths (Graph: /me ≡ /users/{signed-in-user}) — both resolve to whatever this header (or the default MCP user) points at. An explicit path email always wins over the header. The unified MCP server does not send this header — it relies on the per-instance default — so it is primarily for direct REST/HTTP callers."}},"schemas":{"Person":{"type":"object","description":"A person in the canonical directory.","properties":{"id":{"type":"string","readOnly":true,"example":"PERSON-0001"},"email":{"type":"string","format":"email","example":"alice.chen@acme.com"},"firstName":{"type":"string","example":"Alice"},"lastName":{"type":"string","example":"Chen"},"fullName":{"type":"string","example":"Alice Chen"},"title":{"type":"string","example":"Engineering Manager"},"department":{"type":"string","example":"Engineering"},"managerEmail":{"type":"string","nullable":true,"example":"frank.kim@acme.com"},"country":{"type":"string","enum":["IN","AU","US","GB","KR","DE"],"example":"US"},"locale":{"type":"string","example":"en-US"},"city":{"type":"string","example":"San Francisco"},"region":{"type":"string","example":"CA"},"location":{"type":"string","example":"San Francisco, CA"},"timezone":{"type":"string","example":"America/Los_Angeles"},"phone":{"type":"string","example":"+1 (415) 555-1234"},"avatarColor":{"type":"string","example":"#4285F4"},"startDate":{"type":"string","format":"date","example":"2023-04-12"},"workStatus":{"type":"string","enum":["FULL_TIME","CONTINGENT","INTERN"],"example":"FULL_TIME"},"createdAt":{"type":"string","format":"date-time","readOnly":true},"updatedAt":{"type":"string","format":"date-time","readOnly":true}}},"IdentityGatewayUser":{"type":"object","description":"A single user record in the Moveworks Identity Gateway shape (projection of a Person).","properties":{"user":{"type":"object","properties":{"state":{"type":"string","enum":["ACTIVE","INACTIVE"]},"universal_identifier":{"type":"string"},"email_addr":{"type":"string","format":"email"},"first_name":{"type":"string"},"last_name":{"type":"string"},"full_name":{"type":"string"},"work_status":{"type":"string","enum":["UNKNOWN_WORK_STATUS","CONTINGENT","INTERN","FULL_TIME"]},"employment_info":{"type":"object","properties":{"employee_start_date_ts":{"type":"string"},"role":{"type":"string"},"manager_email":{"type":"string"},"cost_center_id":{"type":"string"},"cost_center_name":{"type":"string"},"department":{"type":"string"},"office_phone_number":{"type":"string"},"assistant_full_name":{"type":"string"},"employment_location":{"type":"object","properties":{"location":{"type":"string"},"office":{"type":"string"},"country_code":{"type":"string"},"region":{"type":"string"},"timezone":{"type":"string"}}}}}}},"system_identity":{"type":"object","properties":{"id":{"type":"string"},"username":{"type":"string"}}},"last_updated_at":{"type":"string","format":"date-time"}}}}},"paths":{"/people":{"get":{"summary":"List people in the Purple Identity directory","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Legacy page size (default 50). Prefer $top. Ignored when $top/$skip are used."},{"in":"query","name":"cursor","schema":{"type":"string"},"description":"Legacy keyset cursor — pass the previous response's nextCursor. Prefer $skip/$top."},{"in":"query","name":"$filter","schema":{"type":"string"},"description":"OData filter expression. Comparison operators: eq, ne, gt, ge, lt, le. Logical: and, or, not, with parentheses for grouping. String functions: contains(field,'x'), startswith(field,'x'), endswith(field,'x'). Membership: field in ('a','b'). Case-insensitive matching via tolower(field) / toupper(field). Nested fields use a slash, e.g. pto_balance/vacation_days. String literals use single quotes; dates are ISO-8601. Examples: \"status eq 'active'\"; \"salary gt 50000 and department eq 'Engineering'\"; \"contains(tolower(name),'acme')\"; \"closeDate gt 2026-01-01\"."},{"in":"query","name":"$select","schema":{"type":"string"},"description":"Comma-separated list of fields to return, e.g. \"id,firstName,email\". Nested fields via slash (\"pto_balance/vacation_days\"). The id field is always included."},{"in":"query","name":"$orderby","schema":{"type":"string"},"description":"Sort clause(s), comma-separated, each \"field [asc|desc]\" (asc is the default). Example: \"createdAt desc, lastName asc\". Null values sort last."},{"in":"query","name":"$search","schema":{"type":"string"},"description":"Free-text, case-insensitive substring match across the resource's searchable fields."},{"in":"query","name":"$top","schema":{"type":"integer"},"description":"Maximum number of items to return (page size)."},{"in":"query","name":"$skip","schema":{"type":"integer"},"description":"Number of matching items to skip before returning results (offset paging). Combine with $top."},{"in":"query","name":"$count","schema":{"type":"boolean"},"description":"When true, include @odata.count (total matches after $filter/$search, before paging) in the response."}],"responses":{"200":{"description":"Paginated list","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/Person"}},"nextCursor":{"type":"string","nullable":true,"description":"Cursor for the next page, or null on the last page. With $top/$skip this is the next $skip value; otherwise a keyset cursor."},"total":{"type":"integer","description":"Total documents in the collection."},"@odata.count":{"type":"integer","description":"Present only when $count=true: total matches after $filter/$search."},"@odata.truncated":{"type":"boolean","description":"Present and true only when the collection exceeded the in-memory query cap and results may be incomplete."}}}}}},"400":{"description":"Invalid OData query (e.g. malformed $filter)"}},"description":"The canonical internal roster for this Purple Suite instance — every other app (HRIS, ITSM, Expense, Calendar, PM, …) draws its employees, owners and assignees from these same records, so an email means the same human everywhere. Returns flat Person records (email, first/last/full name, title, department, managerEmail, country, city, timezone, startDate, workStatus). Supports server-side OData querying — $filter, $search, $orderby, $select, $top, $skip, $count (MCP clients see these as _filter, _search, _orderby, _select, _top, _skip, _count because $ is not a legal tool-input key). Example: $filter=department eq 'Engineering' and country eq 'US'. Use this to resolve a person, look up a manager, or build an org view; use identity__list__users only when you need the same people in the Moveworks Identity Gateway wire shape."},"post":{"summary":"Add a person to the Purple Identity directory","parameters":[{"$ref":"#/components/parameters/InstanceId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"responses":{"201":{"description":"Created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}}},"description":"Creates a Person in the instance's canonical internal roster, making that email resolvable across every other Purple app. Use only for internal staff (employees, contingent workers, interns) — external parties such as CRM contacts, ATS candidates or bank customers live in their own app and must not be added here."}},"/people/{id}":{"get":{"summary":"Get one person from the Purple Identity directory","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"404":{"description":"Not found"}},"description":"Fetches a single Person by directory id (e.g. PERSON-0001) from the canonical internal roster. If you only have an email, call identity__list__people with $filter=email eq 'someone@acme.com' instead. Returns the flat Person shape; identity__get__users returns the same human in the nested Identity Gateway shape."},"patch":{"summary":"Update a person's directory record","description":"Partially updates a Person in the canonical roster (e.g. title, department, managerEmail, location, workStatus). Because every other Purple app reads people from here, the change is immediately visible suite-wide. Send only the fields you want to change.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"description":"Fields to update (partial)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"responses":{"200":{"description":"Updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Person"}}}},"404":{"description":"Not found"}}},"delete":{"summary":"Delete a person from the Purple Identity directory","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"404":{"description":"Not found"}},"description":"DESTRUCTIVE and irreversible: permanently removes the Person from the canonical roster, so their email no longer resolves in any Purple app and existing references to them (tickets, expenses, enrollments) are left dangling. For a departure, prefer identity__update__people to change their status rather than deleting the record."}},"/users":{"get":{"x-odata":false,"summary":"List users in the Moveworks Identity Gateway shape","description":"Emulates the Moveworks Identity Gateway 'List Users' contract for user-directory sync. These are the SAME humans as identity__list__people, projected into the gateway's nested envelope ({ results: [{ user, system_identity, last_updated_at }], next_page_token }) — reach for it when you are exercising or debugging a Moveworks identity sync, and for everything else use identity__list__people, which returns the flat, easier-to-read Person records. This endpoint is NOT OData: page with pageToken/pageSize and narrow results with the gateway's own `filter` expression (e.g. filter=department eq 'Engineering'), not $filter/$top.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"query","name":"pageToken","schema":{"type":"string"},"description":"Opaque paging token — pass the previous response's next_page_token."},{"in":"query","name":"pageSize","schema":{"type":"integer","default":1000},"description":"Maximum users to return in one page (default 1000)."},{"in":"query","name":"filter","schema":{"type":"string"},"description":"Filter expression over person fields. Operators eq/ne/gt/lt, combined with and/or and parentheses. Example: \"department eq 'Engineering'\"."}],"responses":{"200":{"description":"A page of users","content":{"application/json":{"schema":{"type":"object","properties":{"results":{"type":"array","items":{"$ref":"#/components/schemas/IdentityGatewayUser"}},"next_page_token":{"type":"string","description":"Token for the next page, or empty on the last page."}}}}}},"400":{"description":"Invalid filter expression"}}}},"/users/{userId}":{"get":{"x-odata":false,"summary":"Get one user in the Moveworks Identity Gateway shape","description":"Emulates the Moveworks Identity Gateway 'Get User by ID' contract. userId is the user's system_identity.id, which is the same value as the directory's person id (e.g. PERSON-0001). Returns one nested gateway user record with no results envelope. Prefer identity__get__people unless you specifically need the gateway wire shape.","parameters":[{"$ref":"#/components/parameters/InstanceId"},{"in":"path","name":"userId","required":true,"schema":{"type":"string"},"description":"The user's system_identity.id (e.g. PERSON-0001)."}],"responses":{"200":{"description":"The user","content":{"application/json":{"schema":{"$ref":"#/components/schemas/IdentityGatewayUser"}}}},"404":{"description":"User not found"}}}}}}