Skip to content

Upsert a user by external ID.

PUT
/tenants/{tenant_id}/users/by-external-id/{external_id}
curl --request PUT \
--url https://shiftagent.example.com/tenants/example/users/by-external-id/example \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{ "email": "jane.doe@acme.example.com", "display_name": "Jane Doe", "role_ids": [ "rol_01hzx8csr001" ] }'

Idempotent get-or-create-or-refresh keyed on the host system’s user ID within the tenant. 201 created / 200 existed. An empty body {} is valid — external-ID-only creation always succeeds; enrich later.

Merge semantics per field: provided → replaced; omitted → unchanged; null → cleared. In particular, omitting role_ids on the warm path leaves role assignments untouched; providing it replaces the full set.

On creation the user is automatically attached to an S3-style storage bucket (storage.provider: "platform"); link a host-owned bucket later via updateUser.

Deactivated ≠ absent: upserting an existing suspended user returns 200 with status: "suspended" and does not reactivate — the adapter must never resurrect users implicitly (reactivate explicitly via updateUser). Token exchange for suspended users fails 403.

Races collapse on the per-tenant uniqueness of external_id: winner 201, loser 200. If a racing caller references roles the winner has not created yet, role_ids may be [] and assigned later via assignUserRole.

tenant_id
required
string
/^tnt_[A-Za-z0-9]+$/

Internal tenant ID.

external_id
required
string
>= 1 characters <= 255 characters

The host system’s identifier, namespaced by the adapter (e.g. {ns}:tenant:{id} / {ns}:user:{id}). Opaque to shiftagent — compared byte-exact after trimming; case-sensitive; URL-encode reserved characters (:%3A where required by the client).

Media type application/json

Merge-upsert body for upsertUserByExternalId. Every field optional — {} performs external-ID-only get-or-create. Per field: provided → replaced, omitted → unchanged, null → cleared. Omitting role_ids leaves role assignments untouched (the warm-path contract); providing it replaces the full set.

object
email

Profile field; null clears.

string | null format: email
display_name

Profile field; null clears.

string | null
<= 255 characters
role_ids

Replaces the full role set when provided (409 cross-tenant if any role belongs to another tenant). [] is valid — assign later via assignUserRole.

Array<string>
default_repository_id

Per-user repository override; null clears.

string | null
/^rep_[A-Za-z0-9]+$/
metadata

Replaces the metadata map wholesale when provided.

object
<= 50 properties
key
additional properties
string
<= 500 characters
Examples

Cold-path step 4 — create the user with its role

{
"email": "jane.doe@acme.example.com",
"display_name": "Jane Doe",
"role_ids": [
"rol_01hzx8csr001"
]
}

User already existed — provided fields merged.

Media type application/json

A host-system end user within one tenant. Effective skills = union over assigned roles of each role’s resolved skills.

object
object
required
string
Allowed value: user
id
required
string
/^usr_[A-Za-z0-9]+$/
tenant_id
required
string
/^tnt_[A-Za-z0-9]+$/
external_id
required

Host system’s user ID (namespaced by the adapter). Unique per tenant; natural key for upsertUserByExternalId.

string
<= 255 characters
email
required

Optional profile field — enrichable after creation.

string | null format: email
display_name
required

Optional profile field — enrichable after creation.

string | null
<= 255 characters
status
required

suspended (deactivated) users fail token exchange and cannot start conversations; they are never auto re-provisioned by upsert.

string
Allowed values: active suspended
role_ids
required

Assigned roles (also manageable via assignUserRole).

Array<string>
default_repository_id
required

Per-user repository override in the resolution cascade.

string | null
/^rep_[A-Za-z0-9]+$/
storage
required

S3-style storage bucket attached to a user or conversation. Platform-assigned automatically at creation; host-owned buckets can be linked via update (provider: "external").

object
provider
required

platform — bucket provisioned and owned by the deployment; external — host-linked BYO bucket.

string
Allowed values: platform external
bucket_uri
required

S3-style URI of the bucket root (e.g. s3://bucket/prefix).

string
metadata
required

Free-form string key–value map for host/adapter bookkeeping (e.g. a host-side reference ID). Max 50 keys; values max 500 chars. Replaced wholesale when provided in updates.

object
<= 50 properties
key
additional properties
string
<= 500 characters
created_at
required

RFC 3339 / ISO 8601 timestamp, UTC.

string format: date-time
updated_at
required

RFC 3339 / ISO 8601 timestamp, UTC.

string format: date-time
Example
{
"object": "user",
"status": "active",
"storage": {
"provider": "platform"
}
}

User created; storage bucket auto-attached.

Media type application/json

A host-system end user within one tenant. Effective skills = union over assigned roles of each role’s resolved skills.

object
object
required
string
Allowed value: user
id
required
string
/^usr_[A-Za-z0-9]+$/
tenant_id
required
string
/^tnt_[A-Za-z0-9]+$/
external_id
required

Host system’s user ID (namespaced by the adapter). Unique per tenant; natural key for upsertUserByExternalId.

string
<= 255 characters
email
required

Optional profile field — enrichable after creation.

string | null format: email
display_name
required

Optional profile field — enrichable after creation.

string | null
<= 255 characters
status
required

suspended (deactivated) users fail token exchange and cannot start conversations; they are never auto re-provisioned by upsert.

string
Allowed values: active suspended
role_ids
required

Assigned roles (also manageable via assignUserRole).

Array<string>
default_repository_id
required

Per-user repository override in the resolution cascade.

string | null
/^rep_[A-Za-z0-9]+$/
storage
required

S3-style storage bucket attached to a user or conversation. Platform-assigned automatically at creation; host-owned buckets can be linked via update (provider: "external").

object
provider
required

platform — bucket provisioned and owned by the deployment; external — host-linked BYO bucket.

string
Allowed values: platform external
bucket_uri
required

S3-style URI of the bucket root (e.g. s3://bucket/prefix).

string
metadata
required

Free-form string key–value map for host/adapter bookkeeping (e.g. a host-side reference ID). Max 50 keys; values max 500 chars. Replaced wholesale when provided in updates.

object
<= 50 properties
key
additional properties
string
<= 500 characters
created_at
required

RFC 3339 / ISO 8601 timestamp, UTC.

string format: date-time
updated_at
required

RFC 3339 / ISO 8601 timestamp, UTC.

string format: date-time
Examples
Example cold_created

New user with auto-attached platform bucket

{
"object": "user",
"id": "usr_01hzx8jane001",
"tenant_id": "tnt_01hzx8acme001",
"external_id": "acme:user:9f27c1",
"email": "jane.doe@acme.example.com",
"display_name": "Jane Doe",
"status": "active",
"role_ids": [
"rol_01hzx8csr001"
],
"default_repository_id": null,
"storage": {
"provider": "platform",
"bucket_uri": "s3://shiftagent-tenant-acme/usr_01hzx8jane001"
},
"metadata": {},
"created_at": "2026-07-02T09:36:00Z",
"updated_at": "2026-07-02T09:36:00Z"
}

Missing or invalid credentials — no bearer token, an unknown/revoked sk_int_ key, or an expired platform JWT.

Media type application/problem+json

RFC 9457 problem+json error envelope. type is a URI under https://shiftagent.example.com/problems/{slug} (deployment host substituted); see the API-level problem registry for every slug.

object
type
required

Problem type URI (registry slug).

string format: uri-reference
title
required

Short, human-readable summary of the problem type.

string
status
required

HTTP status code.

integer format: int32
detail

Human-readable explanation specific to this occurrence.

string
instance

URI reference identifying this occurrence.

string format: uri-reference
request_id

Correlation ID for support and log lookup.

string
conflicting_resource_id

On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).

string
errors

On validation-error, field-level details.

Array<object>
object
pointer
required

JSON pointer to the offending field.

string
message
required

What failed.

string
Examples
Example unauthorized

Missing or invalid bearer token

{
"type": "https://shiftagent.example.com/problems/insufficient-scope",
"title": "Unauthorized",
"status": 401,
"detail": "Provide a valid sk_int_ service key or platform JWT.",
"request_id": "req_01hzx8auth001"
}

Not found — the resource does not exist, was deprovisioned, or lies outside the integration key’s subtree (indistinguishable by design).

Media type application/problem+json

RFC 9457 problem+json error envelope. type is a URI under https://shiftagent.example.com/problems/{slug} (deployment host substituted); see the API-level problem registry for every slug.

object
type
required

Problem type URI (registry slug).

string format: uri-reference
title
required

Short, human-readable summary of the problem type.

string
status
required

HTTP status code.

integer format: int32
detail

Human-readable explanation specific to this occurrence.

string
instance

URI reference identifying this occurrence.

string format: uri-reference
request_id

Correlation ID for support and log lookup.

string
conflicting_resource_id

On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).

string
errors

On validation-error, field-level details.

Array<object>
object
pointer
required

JSON pointer to the offending field.

string
message
required

What failed.

string
Examples
Example not_found

Unknown resource

{
"type": "https://shiftagent.example.com/problems/not-found",
"title": "Not found",
"status": 404,
"detail": "No tenant with external_id acme:tenant:999999.",
"request_id": "req_01hzx8nf001"
}

Conflict — name-conflict / external-id-conflict (unique name or external ID taken; conflicting_resource_id names the holder — fetch it and continue), resource-in-use (guarded delete refused), cross-tenant (referenced resource belongs to another tenant), conversation-archived (write to an archived conversation), approval-expired (approval already resolved or expired), or idempotency-key-conflict (same key, different payload).

Media type application/problem+json

RFC 9457 problem+json error envelope. type is a URI under https://shiftagent.example.com/problems/{slug} (deployment host substituted); see the API-level problem registry for every slug.

object
type
required

Problem type URI (registry slug).

string format: uri-reference
title
required

Short, human-readable summary of the problem type.

string
status
required

HTTP status code.

integer format: int32
detail

Human-readable explanation specific to this occurrence.

string
instance

URI reference identifying this occurrence.

string format: uri-reference
request_id

Correlation ID for support and log lookup.

string
conflicting_resource_id

On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).

string
errors

On validation-error, field-level details.

Array<object>
object
pointer
required

JSON pointer to the offending field.

string
message
required

What failed.

string
Examples

Named sub-resource already exists — recoverable

{
"type": "https://shiftagent.example.com/problems/name-conflict",
"title": "Name conflict",
"status": 409,
"detail": "A role named \"csr\" already exists in this tenant.",
"conflicting_resource_id": "rol_01hzx8csr001",
"request_id": "req_01hzx8conf01"
}

Unprocessable — validation-error (schema/semantic validation failed; errors[] lists JSON-pointer details) or role-required (user has multiple roles and no role_id was given).

Media type application/problem+json

RFC 9457 problem+json error envelope. type is a URI under https://shiftagent.example.com/problems/{slug} (deployment host substituted); see the API-level problem registry for every slug.

object
type
required

Problem type URI (registry slug).

string format: uri-reference
title
required

Short, human-readable summary of the problem type.

string
status
required

HTTP status code.

integer format: int32
detail

Human-readable explanation specific to this occurrence.

string
instance

URI reference identifying this occurrence.

string format: uri-reference
request_id

Correlation ID for support and log lookup.

string
conflicting_resource_id

On name-conflict, external-id-conflict, and resource-in-use: the ID of the existing/depended-on resource — fetch it and continue (replay recovery).

string
errors

On validation-error, field-level details.

Array<object>
object
pointer
required

JSON pointer to the offending field.

string
message
required

What failed.

string
Examples

Field-level validation failure

{
"type": "https://shiftagent.example.com/problems/validation-error",
"title": "Validation error",
"status": 422,
"detail": "One or more fields failed validation.",
"errors": [
{
"pointer": "/skill_access/skill_ids/0",
"message": "skl_01hzx8unknown does not belong to the effective repository."
}
],
"request_id": "req_01hzx8val001"
}