Journey. As a developer, I want my app to let people see and edit their
profile, find other users, and close their account — and I want to understand
exactly what happens to a user record over its whole lifetime.
The Users platform is a CQRS pair: Users Command (USC) owns every write, Users Query (USQ) owns every read. This guide walks the full lifecycle of a user record — born at first sign-in, fleshed out with profile data, read back in several ways, removed, and born again — verified against the local stack (USQ :40170, USC :40171); the shared dev subdomains behave identically.
Prerequisites
- Both Users services are reachable (local stack USQ
:40170/ USC:40171,
or the shared dev subdomains).
- Your tenant key pair in
INAYA_API_KEY/INAYA_APP_KEY, and a user JWT
in TOKEN (obtain one via guide G-01; the full example below fetches one via the QA sign-in shortcut).
curlpluspython3for the JSON-editing step (any HTTP client works; the
same calls translate directly).
Concepts (only what this journey needs)
- Users are born at sign-in. There is no "create user" endpoint. The first
time an identity signs in, the platform creates a bare user record: the username becomes the initial user_name / display_name, email/phone and the remaining profile fields are empty, the role defaults to Subscriber, and profile_json is unset. Delete that record and the next sign-in simply creates a fresh one.
- *The JWT
subclaim is* the user id.** Every authenticated request knows
who it belongs to; extract sub from the token and that is your /v3/users/{id}.
- CQRS split. All writes go to Users Command (
PUT,DELETEon
/v3/users/{id}); all reads come from Users Query (GET on /v3/users/{id}, POST /v3/search, POST /v3/users/batch, follower lists).
- Writes are self-only. A JWT may only
PUTorDELETEthe user whose id
equals its sub; touching any other id returns 401.
- Update = get, modify, put the whole DTO.
PUTexpects a complete
UserDto. Partial bodies fail with 422 ("missing field …"). And although you must send the whole DTO, only profile fields are actually written: names, display_name, bio, avatar_url, gender, mantra, location_display_name, social_handles, websites, profile_json, and the sentiment fields. email_address, phone_number, user_name, and roles are ignored by this call — email and phone have their own dedicated storage paths, roles are managed by the platform.
user_namemirrorsdisplay_name. Setting a display name makes
user_name read back with the same value; clients should treat display name as the source of truth.
profile_jsonis your free-form corner. Any JSON object your app needs
(preferences, app-specific flags, onboarding state) rides along on the user record.
Step 1 — Who am I?
Decode your user id from the JWT's sub claim, then read the record from Users Query:
USER_ID=$(python3 - <<'EOF'
import base64, json, os
segment = os.environ["TOKEN"].split(".")[1]
segment += "=" * (-len(segment) % 4)
print(json.loads(base64.urlsafe_b64decode(segment))["sub"])
EOF
)
curl -s "${USQ_URL}/v3/users/${USER_ID}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}"Expected response — 200 with the full UserDto. A freshly-born user reads back with the username as both user_name and display_name, empty strings for first_name / last_name / email_address / phone_number, roles: ["Subscriber"], and profile_json: null.
Step 2 — Flesh out the profile
The reliable update pattern is read the current DTO, change the fields you own, put it back whole. Here we set names, display name, bio, location, and a free-form profile_json, then read back to prove it:
USER_ID=$(python3 - <<'EOF'
import base64, json, os
segment = os.environ["TOKEN"].split(".")[1]
segment += "=" * (-len(segment) % 4)
print(json.loads(base64.urlsafe_b64decode(segment))["sub"])
EOF
)
AUTH=(-H "Authorization: Bearer ${TOKEN}"
-H "x-api-key: ${INAYA_API_KEY}"
-H "x-client-app-key: ${INAYA_APP_KEY}")
curl -s "${USQ_URL}/v3/users/${USER_ID}" "${AUTH[@]}" > /tmp/me.json
python3 - <<'EOF'
import json
me = json.load(open("/tmp/me.json"))
me["first_name"] = "Dana"
me["last_name"] = "Verifier"
me["display_name"] = "Dana V."
me["bio"] = "Owns the demo farm account."
me["location_display_name"] = "Demo Valley"
me["profile_json"] = {"favorite_animal": "chickens", "onboarded": True}
json.dump(me, open("/tmp/me-updated.json", "w"))
EOF
curl -s -X PUT "${USC_URL}/v3/users/${USER_ID}" "${AUTH[@]}" \
-H "Content-Type: application/json" \
--data-binary @/tmp/me-updated.json
echo
curl -s "${USQ_URL}/v3/users/${USER_ID}" "${AUTH[@]}" \
| python3 -c 'import json,sys; u=json.load(sys.stdin); print(json.dumps({k: u[k] for k in ("display_name","user_name","bio","location_display_name","profile_json")}, indent=2))'Expected response — the PUT returns 200 with the updated UserDto, and the follow-up read shows display_name: "Dana V.", user_name: "Dana V." (mirrored), the bio and location you set, and your profile_json object intact. Fields you did not touch keep their previous values; email_address, phone_number, user_name, and roles come back exactly as they were regardless of what you sent.
Step 3 — Find users (search & batch)
Users Query offers two discovery reads. POST /v3/search matches a criteria object — each of name (first/last/display/nick), email, and phone_number is optional but length-validated — and returns a plain JSON array (no paging envelope). POST /v3/users/batch resolves a list of ids to full records in one round trip:
curl -s -X POST "${USQ_URL}/v3/search" \
-H "Authorization: Bearer ${TOKEN}" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Content-Type: application/json" \
-d '{"search_criteria":[{"name":"Dana"}]}'
echo
USER_ID=$(python3 - <<'EOF'
import base64, json, os
segment = os.environ["TOKEN"].split(".")[1]
segment += "=" * (-len(segment) % 4)
print(json.loads(base64.urlsafe_b64decode(segment))["sub"])
EOF
)
curl -s -X POST "${USQ_URL}/v3/users/batch" \
-H "Authorization: Bearer ${TOKEN}" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-H "Content-Type: application/json" \
-d "{\"ids\":[\"${USER_ID}\"]}"Expected response — both calls return 200 with a raw JSON array of matching UserDtos. A name that matches nothing returns [], not an error. (page / page_size query params are accepted by search when you need to page large result sets.)
Step 4 — Remove a user (and watch it be reborn)
DELETE is self-only and idempotent: it returns 204 even when repeated. Afterwards the record reads 404 — and the next sign-in creates it anew, empty, exactly like the very first sign-in:
USER_ID=$(python3 - <<'EOF'
import base64, json, os
segment = os.environ["TOKEN"].split(".")[1]
segment += "=" * (-len(segment) % 4)
print(json.loads(base64.urlsafe_b64decode(segment))["sub"])
EOF
)
AUTH=(-H "Authorization: Bearer ${TOKEN}"
-H "x-api-key: ${INAYA_API_KEY}"
-H "x-client-app-key: ${INAYA_APP_KEY}")
curl -s -o /dev/null -w "DELETE -> HTTP %{http_code}\n" \
-X DELETE "${USC_URL}/v3/users/${USER_ID}" "${AUTH[@]}"
curl -s -o /dev/null -w "GET after delete -> HTTP %{http_code}\n" \
"${USQ_URL}/v3/users/${USER_ID}" "${AUTH[@]}"
# Same identity, next sign-in: a brand-new record is born at a NEW id.
TOKEN=$(curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g30-rebirth\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g30-verify\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
export TOKEN
NEW_ID=$(python3 - <<'EOF'
import base64, json, os
segment = os.environ["TOKEN"].split(".")[1]
segment += "=" * (-len(segment) % 4)
print(json.loads(base64.urlsafe_b64decode(segment))["sub"])
EOF
)
curl -s "${USQ_URL}/v3/users/${NEW_ID}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
| python3 -c 'import json,sys; u=json.load(sys.stdin); print("reborn:", u["display_name"] == "g30-verify" and u["profile_json"] is None)'Expected response — DELETE -> HTTP 204, GET after delete -> HTTP 404, then reborn: True. Because removal is a hard delete, the re-signed-in identity gets a new user id (NEW_ID ≠ the deleted USER_ID) with a fresh, empty profile — the username appears as the initial user_name / display_name. (Production sign-in flows key off the verified identity, so the profile you rebuild is the one your app sees; the QA shortcut keys off the username the same way.) The username here (g30-verify) is arbitrary — it only needs a distinct value per run.
Error contract
Two failure modes are worth asserting in your client. Note the second one deliberately sends a well-formed DTO: the body must deserialize into a complete UserDto before the handler runs, so a malformed body aimed at someone else's id fails with 422 (missing fields) before ownership is ever considered.
AUTH=(-H "Authorization: Bearer ${TOKEN}"
-H "x-api-key: ${INAYA_API_KEY}"
-H "x-client-app-key: ${INAYA_APP_KEY}")
# Unknown id -> 404 with a plain error body.
curl -s -w "\nHTTP %{http_code}\n" "${USQ_URL}/v3/users/no-such-user" "${AUTH[@]}"
# Writing to someone else's id -> 401 (self-only writes). The DTO is built
# locally so this block does not depend on any particular user existing.
python3 - <<'EOF'
import json
dto = {
"id": "somebody-else", "user_name": "demo-user",
"display_name": "demo-user", "first_name": "", "last_name": "",
"email_address": "demo@example.com", "phone_number": "", "gender": "",
"bio": None, "mantra": None, "avatar_url": None,
"location_display_name": None, "middle_name": None, "nick_name": None,
"roles": ["Subscriber"], "sentiment_label": "Neutral",
"sentiment_score": 0.0, "social_handles": None, "websites": None,
"profile_json": None, "translated": False,
"birth_date": "2026-01-01T00:00:00Z",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
}
json.dump(dto, open("/tmp/g30-error-dto.json", "w"))
EOF
curl -s -o /dev/null -w "cross-user PUT -> HTTP %{http_code}\n" \
-X PUT "${USC_URL}/v3/users/somebody-else" "${AUTH[@]}" \
-H "Content-Type: application/json" \
--data-binary @/tmp/g30-error-dto.jsonExpected response — the unknown-id read returns {"error":"User not found"} with 404; the cross-user write returns 401.
Full working example
A complete born → flesh out → read → search → remove → reborn cycle (user_lifecycle.sh) — self-contained, fetching its JWT via the QA shortcut (local/QA environments only; production uses the G-01 flows):
#!/usr/bin/env bash
set -euo pipefail
# 0. Obtain a user JWT (QA shortcut — see G-01 for production flows)
TOKEN=$(curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g30-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g30-demo-run\"}" \
| python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
export TOKEN
AUTH=(-H "Authorization: Bearer ${TOKEN}"
-H "x-api-key: ${INAYA_API_KEY}"
-H "x-client-app-key: ${INAYA_APP_KEY}")
USER_ID=$(python3 -c 'import base64,json,os
segment = os.environ["TOKEN"].split(".")[1]
segment += "=" * (-len(segment) % 4)
print(json.loads(base64.urlsafe_b64decode(segment))["sub"])')
export USER_ID
# 1. Born at sign-in: read the fresh, empty record
curl -sf "${USQ_URL}/v3/users/${USER_ID}" "${AUTH[@]}" > /tmp/g30-me.json
echo "born: id=${USER_ID}"
# 2. Flesh out (get -> modify -> put the whole DTO)
python3 - <<'EOF'
import json
me = json.load(open("/tmp/g30-me.json"))
me["first_name"] = "Dana"
me["last_name"] = "Verifier"
me["display_name"] = "Dana V."
me["bio"] = "Owns the demo farm account."
me["location_display_name"] = "Demo Valley"
me["profile_json"] = {"favorite_animal": "cows", "onboarded": True}
json.dump(me, open("/tmp/g30-me-updated.json", "w"))
EOF
curl -sf -X PUT "${USC_URL}/v3/users/${USER_ID}" "${AUTH[@]}" \
-H "Content-Type: application/json" \
--data-binary @/tmp/g30-me-updated.json > /dev/null
echo "profile updated"
# 3. Read back via search and batch
curl -sf -X POST "${USQ_URL}/v3/search" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{"search_criteria":[{"name":"Dana"}]}' | grep -q '"Dana V."' \
&& echo "search found the profile"
curl -sf -X POST "${USQ_URL}/v3/users/batch" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d "{\"ids\":[\"${USER_ID}\"]}" | grep -q "${USER_ID}" \
&& echo "batch resolved the id"
# 4. Remove (idempotent 204), then confirm 404
code=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE \
"${USC_URL}/v3/users/${USER_ID}" "${AUTH[@]}")
[[ "$code" == "204" ]] && echo "deleted (204)"
code=$(curl -s -o /dev/null -w "%{http_code}" \
"${USQ_URL}/v3/users/${USER_ID}" "${AUTH[@]}")
[[ "$code" == "404" ]] && echo "gone (404)"
# 5. Rebirth at next sign-in
curl -sf -X POST "${AUTH_URL}/v3/auth/qa/login" \
-H "Content-Type: application/json" \
-H "x-api-key: ${INAYA_API_KEY}" \
-H "x-client-app-key: ${INAYA_APP_KEY}" \
-d "{\"device_id\": \"g30-demo\", \"locale\": \"en-US\", \"tenant_id\": \"${TENANT_ID}\", \"username\": \"g30-demo-run\"}" \
> /dev/null
echo "re-signed-in; profile state resets to empty on the platform side"(AUTH_URL is the Auth Service base URL — http://127.0.0.1:40004 on the local stack; TENANT_ID is your tenant's id; USQ_URL / USC_URL are the Users Query / Users Command base URLs.)
Try it live
Open the Playground → Users & Profile Matching — the Users console exercises the same get → update → search → remove cycle against the live Users services.
Copy agent prompt
You are working with the Inaya Labs Users platform. USQ (reads) and USC
(writes) base URLs are in USQ_URL / USC_URL; the Auth Service is at AUTH_URL;
the tenant key pair is in INAYA_API_KEY / INAYA_APP_KEY and TENANT_ID. Obtain
a user JWT via POST /v3/auth/qa/login {"device_id","locale","tenant_id",
"username"} if TOKEN is not already set; the JWT "sub" claim is the user id.
Steps: (1) GET /v3/users/{sub} from USQ and report whether profile_json is
set. (2) Update the profile: fetch the full UserDto, set display_name to
"Agent Friend", bio to "Updated by an agent", profile_json to
{"agent_visit": true}; PUT the whole DTO to USC /v3/users/{sub} (expect 200)
and confirm via a fresh GET. (3) Search via POST USQ /v3/search
{"search_criteria":[{"name":"Agent Friend"}]} and confirm the user appears in
the returned array. (4) Attempt PUT to /v3/users/somebody-else and report the
401. (5) DELETE /v3/users/{sub} (expect 204), confirm a subsequent GET is 404,
then re-login and report that the profile reads back empty. Never log the JWT;
report status codes per step.API reference
Next journeys
- Federated sign-in for your app (G-31) — list federated login methods and complete a sign-in flow end-to-end.
- Finding matches between profiles (G-32) — collections, profile indexing, and threshold-based search.
- Authentication & sessions (G-01) — the token lifecycle that every call in this guide depends on.