Creating a user account via the API — exact steps, sample data & test assertions
This is the companion write-up for the question Can I create a new user as Admin from the Admin interface? — that answer said "use the API to create an account outright." Here is the exact how: every call, the data it needs, how to verify it worked, how to tear it down, and the error assertions — given generically and with copy-pasteable example data, so a human, an AI, or a test harness can drive it.
Every call below was run end-to-end against our dev instance (talkyard.dev.ivanthegeek.com, server v1.2026.003) on 2026-07-05; the example responses are the real bytes it returned. Prod runs the same server version, so behavior is identical — with one exception called out in Step 3 (login-secret TTL).
⚠️ Run this against dev, not prod. The sample
ssoId/email/username below are dev fixtures. Creating them verbatim on the prod forum would mint a real account. Also: theidvalues (101, …) are instance-specific — never hardcode them in assertions (on prod, id101is already the realClaudeaccount). Always assert against theidyou capture from Step 1's response.
TL;DR
POST /-/v0/upsert-user with the sysbot API secret creates a member account (or updates it in place if the ssoId already exists). No password is set, no verification email is sent, and you never leave your own admin session. One call = one account.
To also sign that user in (drop them into a logged-in browser session), do the optional two-step login-secret flow (Step 3).
Setup & prerequisites
Define these once; every snippet below uses them:
ORIGIN=https://talkyard.dev.ivanthegeek.com # prod: https://forum.ivanthegeek.com
API_SECRET=$(cat .talkyard-dev-api-secret) # never echo/commit this
| What | Value | Notes |
|---|---|---|
| Auth | HTTP Basic, username tyid=2, password = $API_SECRET |
tyid=2 is sysbot, an admin |
| API secret | .talkyard-dev-api-secret (dev) / .talkyard-api-secret (prod), chmod 600 |
Admin Area → API tab to (re)create |
| Site setting | enableApi = true |
default true; else every call → 403 TyEAPI0ENB_ |
A site API secret is a root credential — it can act as any member and works on every endpoint. Guard it like a password.
Step 1 — Create the user
POST /-/v0/upsert-user
Authorization: Basic base64("tyid=2:<API_SECRET>")
Content-Type: application/json
The body is a flat ExternalUser JSON object (do not wrap the fields in a sub-object):
| Field | Type | Required | Notes |
|---|---|---|---|
ssoId |
string | yes | Your external/stable identity for this user. This is the key Talkyard upserts on. |
primaryEmailAddress |
string | yes | Validated. Missing → 400 TyEBADJSN. |
isEmailAddressVerified |
boolean | yes | Must be true or the call 403s (TyESSOEMLUNVERF). You are asserting the email is already verified on your side; Talkyard sends no verification mail for API-created users. |
username |
string | no | Trimmed; illegal chars auto-sanitized and the name may be uniquified. Read the returned username back — it can differ from what you sent. |
fullName |
string | no | Display name. |
extId |
string | no | A second external id, usable later in API refs as extid:<id>. |
aboutUser |
string | no | Bio. |
avatarUrl |
string | no | |
isAdmin |
boolean | no (default false) | Applied only at creation. Cannot be flipped later via upsert. |
isModerator |
boolean | no (default false) | Same — creation-only. |
Generic:
curl --user "tyid=2:$API_SECRET" -H 'Content-Type: application/json' \
-d '{
"ssoId": "<STABLE_EXTERNAL_ID>",
"primaryEmailAddress": "<EMAIL>",
"isEmailAddressVerified": true,
"username": "<USERNAME>",
"fullName": "<FULL NAME>"
}' \
"$ORIGIN/-/v0/upsert-user"
Example (real data used on dev):
curl --user "tyid=2:$API_SECRET" -H 'Content-Type: application/json' \
-d '{
"ssoId": "paths-demo-001",
"extId": "paths-ext-001",
"primaryEmailAddress": "alice.paths@example.com",
"isEmailAddressVerified": true,
"username": "alice_paths",
"fullName": "Alice Paths",
"aboutUser": "Created via API for PATHS demo."
}' \
"$ORIGIN/-/v0/upsert-user"
Response (200) — real bytes:
{"user":{"id":101,"username":"alice_paths","fullName":"Alice Paths","extId":"paths-ext-001","ssoId":"paths-demo-001"}}
id is the new Talkyard user id — capture it. username is the canonical (possibly rewritten) name — build profile links from this. extId/ssoId are echoed only because the caller is admin/sysbot; a non-admin caller wouldn't see them.
Step 2 — Verify the account exists
Three independent methods; any one suffices. Each has a suggested assertion and a note on what it proves.
2a. GET /-/v0/list-users — quick, public (no secret)
curl "$ORIGIN/-/v0/list-users?usernamePrefix=alice"
{"users":[{"id":101,"username":"alice_paths","fullName":"Alice Paths"}]}
Assert: users[] contains an entry whose id equals the id from Step 1 (a prefix can match >1 account — assert "contains", not "single row"). Proves: the account is listable. Doesn't prove: email/flags landed, or that it can log in. Note: this endpoint is unauthenticated — handy for verification, but also a username-enumeration surface (max 50, no ssoId/extId).
2b. POST /-/v0/get — authoritative, resolves by ssoId/extId (admin secret)
curl --user "tyid=2:$API_SECRET" -H 'Content-Type: application/json' \
-d '{"getQuery":{"getWhat":"Pats","getRefs":["ssoid:paths-demo-001"]}}' \
"$ORIGIN/-/v0/get"
{"origin":"https://talkyard.dev.ivanthegeek.com","thingsOrErrs":[{"id":101,"extId":"paths-ext-001","refId":"paths-ext-001","ssoId":"paths-demo-001","username":"alice_paths","fullName":"Alice Paths"}]}
Assert: thingsOrErrs[0].id == the captured id and .ssoId == the ssoId you sent, and thingsOrErrs[0].errCode is absent (a not-found ref comes back as {"errCode":"TyEPATNF_", ...} in that slot). Proves: the external identity mapped correctly. ssoid:/extid: refs require an admin secret; username:alice_paths / userid:101 work without exposing external ids. To positively confirm isAdmin/isModerator (creation-only, silently ignored on later upserts), assert on those fields here right after creation.
2c. Database (optional, needs DB access) — verified against the real dev DB
SELECT user_id, username, full_name, primary_email_addr, sso_id, ext_id, password_hash
FROM users3
WHERE sso_id = 'paths-demo-001';
Assert: exactly one row; sso_id/ext_id match; password_hash is NULL (API users have no password). Column names above are exact for v1.2026.003.
Step 3 (optional) — Sign the created user in
Two server-to-server calls yield a one-time link that logs a browser in as the user — for when the account must be active in a session, not just exist.
⏱️ TTL differs by environment: the login secret lives ~30 s on prod but ~30 min on dev. Automated redemption that works on dev can intermittently fail on prod — redeem within seconds there.
🔒 The
loginSecretand the redeem URL are bearer credentials that grant a live session as the user — never log them, put them in shell history, or store them in CI logs. (Because the sysbot secret can mint a login secret for almost any member, this same flow is an impersonation primitive.)
3a. Mint a one-time login secret
curl --user "tyid=2:$API_SECRET" -H 'Content-Type: application/json' \
-d '{"ssoId":"paths-demo-001","primaryEmailAddress":"alice.paths@example.com","isEmailAddressVerified":true}' \
"$ORIGIN/-/v0/upsert-user-get-login-secret"
{"loginSecret":"130jvfmdk6r8hicvz7yvh134wr","ssoLoginSecret":"130jvfmdk6r8hicvz7yvh134wr"}
(ssoLoginSecret is a deprecated duplicate — use loginSecret.) This endpoint is insert-or-link only — it will not update an existing user's profile. Push profile changes with /-/v0/upsert-user (Step 1).
3b. Redeem it in the browser
GET $ORIGIN/-/v0/login-with-secret?oneTimeSecret=<loginSecret>&thenGoTo=/-/users/alice_paths
Real response headers:
HTTP/2 307
location: /-/users/alice_paths
set-cookie: __Host-TyCoSid123=…; Path=/; Secure
set-cookie: __Host-TyCoSid4=…; Path=/; Secure; HTTPOnly
Assert: HTTP 307, Location == your thenGoTo, Set-Cookie present. Also:
- One-time. A second redemption → 403
TyELGISECR_EMANY_(verified). - Percent-encode
thenGoTo. An unencoded space triggers a known HTTP 500 bug. Literal__dwHash__in the path becomes#. - Cross-origin
thenGoTomust be same-origin or in the Allow-Embedding-From list, else 403TyEEXTREDIR1.
Idempotency & updates
Re-running Step 1 with the same ssoId and a changed field updates the existing account in place — same id back:
# second call, only fullName changed
-d '{"ssoId":"paths-demo-001","primaryEmailAddress":"alice.paths@example.com","isEmailAddressVerified":true,"username":"alice_paths","fullName":"Alice Paths RENAMED"}'
{"user":{"id":101,"username":"alice_paths","fullName":"Alice Paths RENAMED","extId":"paths-ext-001","ssoId":"paths-demo-001"}}
- Only
POST /-/v0/upsert-userupdates existing users. The login-secret and token endpoints are insert/link-only. isAdmin/isModeratortake effect only at creation.- Changing a user's email via upsert to an address owned by a different account → 403
TyEUPSDUPEML_. - If a passwordless/OAuth account already has the (verified) email you send under a new
ssoId, Talkyard links them (adopts thessoId) rather than duplicating; a conflict with a different existingssoId→ 403TyESSOEMLCONFL_.
Cleanup / teardown
There is no public v0 delete endpoint. Teardown uses the internal staff endpoint (same sysbot secret), which anonymizes the account rather than hard-deleting it:
curl --user "tyid=2:$API_SECRET" -H 'Content-Type: application/json' \
-d '{"userId": 101}' "$ORIGIN/-/delete-user"
)]}',
"anon3132512697"
The response is the new anonymized username. (Internal /-/… endpoints prefix JSON with the anti-hijack guard )]}', — strip the first line before parsing; the public /-/v0/… endpoints do not.) Afterwards the row persists with username=anonNNN… and full_name/email/ssoId stripped, so it still appears in list-users.
For a repeatable harness, prefer a unique
ssoId/email per run (e.g.paths-demo-<runid>) over teardown — upsert is idempotent, so re-using one id never errors, but randomizing avoids accumulating anonymized rows and prefix-match collisions in Step 2a.
Error cases (verified — negative-test assertions)
Errors come back as text/plain, not JSON. The TyE… code appears in brackets at the end of the body; 403 responses additionally set an X-Error-Code response header equal to the code. Match on the bracketed code or (for 403) the header — not on the leading text.
| Trigger | HTTP | Error code | Where |
|---|---|---|---|
isEmailAddressVerified: false |
403 | TyESSOEMLUNVERF |
body + X-Error-Code |
Missing primaryEmailAddress |
400 | TyEBADJSN |
body (Bad JSON: … [TyEBADJSN]) |
| Wrong/deleted secret | 403 | TyEAPI0SECRET01_ |
body + X-Error-Code |
enableApi = false |
403 | TyEAPI0ENB_ |
body + X-Error-Code |
| Update email to another account's | 403 | TyEUPSDUPEML_ |
body + X-Error-Code |
| Email already mirrors a different ssoId | 403 | TyESSOEMLCONFL_ |
body + X-Error-Code |
Example real 403 body:
403 Forbidden
No such API secret or it has been deleted [TyEAPI0SECRET01_]
Machine-readable fixture (for PATHS / test harnesses)
Self-contained: request in, expectations out, verifications, negatives, teardown. Targets dev.
{
"name": "create-user-happy-path",
"instance": "dev",
"baseUrl": "https://talkyard.dev.ivanthegeek.com",
"auth": {"basicUser": "tyid=2", "basicPassSecretRef": ".talkyard-dev-api-secret (relative to workspace root)"},
"request": {
"method": "POST", "path": "/-/v0/upsert-user",
"headers": {"Content-Type": "application/json"},
"body": {
"ssoId": "paths-demo-001",
"extId": "paths-ext-001",
"primaryEmailAddress": "alice.paths@example.com",
"isEmailAddressVerified": true,
"username": "alice_paths",
"fullName": "Alice Paths"
}
},
"expect": {
"status": 200,
"jsonPath": {"user.username": "alice_paths", "user.ssoId": "paths-demo-001", "user.extId": "paths-ext-001"},
"capture": {"userId": "user.id"}
},
"verify": [
{"method": "POST", "path": "/-/v0/get",
"body": {"getQuery": {"getWhat": "Pats", "getRefs": ["ssoid:paths-demo-001"]}},
"expect": {"status": 200, "jsonPath": {"thingsOrErrs.0.id": "${userId}", "thingsOrErrs.0.ssoId": "paths-demo-001"}, "absent": ["thingsOrErrs.0.errCode"]}},
{"method": "GET", "path": "/-/v0/list-users?usernamePrefix=alice",
"expect": {"status": 200, "containsWhere": {"users[*].id": "${userId}"}}}
],
"negativeCases": [
{"body": {"ssoId": "paths-neg-verif", "primaryEmailAddress": "neg1@example.com", "isEmailAddressVerified": false},
"expect": {"status": 403, "errorCode": "TyESSOEMLUNVERF", "bodyContains": "TyESSOEMLUNVERF"}},
{"body": {"ssoId": "paths-neg-nomail", "isEmailAddressVerified": true},
"expect": {"status": 400, "bodyContains": "TyEBADJSN"}}
],
"teardown": {"method": "POST", "path": "/-/delete-user", "body": {"userId": "${userId}"},
"note": "internal endpoint; strip leading )]}', line; anonymizes (does not hard-delete)",
"expect": {"status": 200}}
}
Notes for the harness: userId is captured, never hardcoded (it differs per instance). Negative cases use their own throwaway ssoIds so they always hit the create path. Error assertions match on the code, which sits in brackets in the body and in the X-Error-Code header on 403s.
Source pointers
appsv/server/talkyard/server/authn/SsoAuthnController.scala—apiV0_upsertUser(create/update),apiv0_upsertUserGenLoginSecret,apiv0_loginWithSecret.appsv/server/talkyard/server/JsX.scala—apiV0_parseExternalUser(request parsing),JsUserApiV0(response shape).appsv/model/src/main/scala/com/debiki/core/user.scala—case class ExternalUser(field validation).appsv/server/controllers/UserController.scala—deleteUser(teardown),listMembersPubApi.- Deep-dive with every field, error code and edge case: workspace
research/notes/sso-and-user-apis.md.
Answers and extends the admin-UI question. If anything here drifts after a server upgrade, re-run the fixture against dev and update this post.