Create a user
POST /v1/users · permission users:write
Creates a user in the environment of the API key. The user belongs to that environment only; the same email in two environments is two separate users. Unique values (email, phone_number, username, external_id) are checked per environment. Send an Idempotency-Key header to make retries safe.
Request
The body is a JSON object. Unknown fields are rejected with unknown_field.
| Field | Type | Required | Description |
|---|---|---|---|
email |
string | no* | Primary email address. |
email_verified |
boolean | no | Marks the email as verified. Default false. |
phone_number |
string | no* | Primary phone number in E.164 format. |
phone_verified |
boolean | no | Marks the phone number as verified. Default false. |
phone_country_code |
string | no | ISO 3166-1 alpha-2 code. Derived from the phone prefix when empty. |
username |
string | no* | 3 to 64 characters. Unique in the environment. |
external_id |
string | no | Your own identifier, 1 to 255 characters. Unique in the environment. |
name, given_name, family_name, middle_name, nickname |
string | no | Profile names. |
picture, website, profile_url |
string | no | https:// URLs, up to 2048 characters. |
locale, zoneinfo, gender, birthdate |
string | no | Profile attributes. birthdate is YYYY-MM-DD. |
address |
object | no | OIDC address object, up to 2048 bytes. |
user_metadata, app_metadata |
object | no | Free-form JSON objects, up to 16 KiB, depth 5, 100 top-level keys. |
* At least one of email, phone_number or username is required.
Response
201 Created. The body is the user object. The ETag header carries the user version.
Personal data is masked when the key lacks the users:read_pii permission: email becomes a***@e***.com, phone_number keeps only its last 2 digits (+**********67), name, given_name, family_name, middle_name and nickname become initials (A. L.), username keeps only its first character (a***), birthdate becomes ****-**-** and address becomes {"redacted": true}. user_metadata and app_metadata are not masked.
{
"id": "usr_0a1b2c3d4e5f6g7h8j9k0m1n2p",
"kind": "regular",
"status": "active",
"external_id": null,
"username": null,
"name": "Ada Lovelace",
"email": "ada@example.com",
"email_verified": true,
"phone_number": null,
"phone_verified": false,
"user_metadata": {},
"app_metadata": {},
"created_via": "admin_api",
"created_at": "2026-01-15T09:30:00Z",
"updated_at": "2026-01-15T09:30:00Z",
"last_sign_in_at": null,
"deletion_requested_at": null,
"deletion_due_at": null
}
Errors
Every error is an application/problem+json body with a stable code.
| HTTP | code |
When |
|---|---|---|
| 400 | malformed_request, unknown_field |
The body is not valid JSON or has a field that is not in the schema. |
| 409 | conflict |
The value is already in use. errors[] names the field and the reason (email_taken, phone_taken, username_taken, external_id_taken). |
| 415 | unsupported_media_type |
Content-Type is not application/json. |
| 422 | validation_failed |
A value is invalid; errors[].pointer names the field. |
| 401 | unauthenticated |
The API key is missing, invalid or revoked. |
| 403 | insufficient_permissions |
The key lacks the users:write permission. |
| 429 | rate_limited |
The rate limit was exceeded; honor Retry-After. |
| 503 | service_unavailable |
A dependency is temporarily unavailable; retry later. |
Example
curl -X POST https://api.eu.keenauth.com/v1/users \
-H "Authorization: Bearer $KEENAUTH_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"email": "ada@example.com", "email_verified": true, "name": "Ada Lovelace"}'
Personal data
This endpoint carries personal data: end-user profile. Personal data in the KeenAuth APIs describes what is stored, how long it is kept and how it is deleted. Do not send special categories of personal data (health, biometric data, religion, political opinions, sexual orientation) in any field.