Preferences
Anikeen ID keeps small per-user settings as JSON values under a namespace and a key, so they follow the user across devices and apps:
globalis shared by all apps. Only registered keys and values are accepted: todayappearancewithsystem,lightordark.- App namespaces (e.g.
acme) hold one app's own data: onboarding state, a default workspace, the user's customer ID at a payment provider. Staging and production keep separate data and registrations; you can use the same namespace in both or a separate one on staging (e.g.acme-staging).
Access
Access never comes from scopes alone: any client can request any scope.
| Caller | global | own app namespace | namespace with read access | other or unknown |
|---|---|---|---|---|
| user token (any app) | read, write | 404 | 404 | 404 |
| app token of a trusted app | read, write if registered for global, else 404 | read, write, lookup | read only | 404 |
| app token of an untrusted app | 403 | 403 | 403 | 403 |
Ask Anikeen to register your OAuth client(s) – the app has to be trusted – for
- the namespaces your app owns (read, write and the lookup),
- read access to another app's namespace,
globalwith the app's token (users always read and write their ownglobalwith their token),- keys whose values are unique (see the lookup).
An unknown namespace and one you may not read look the same: 404 namespace_not_found.
Scopes. User tokens need preferences (read and write) or preferences:read; app tokens preferences:client.
Limits
- A value is at most 2048 bytes of JSON and 4 levels deep.
- At most 50 keys per user and namespace (updating a key always works).
- Keys match
^[a-z][a-z0-9_]{0,63}$. - Per minute: 60 requests per user and app with user tokens, 600 per app with the app token and 120 lookups per app (
429 rate_limitedwithRetry-After).
Not a secrets store
Values are stored as plain JSON and shown to Anikeen's support and to the user on a request for their data. Store IDs and settings, never passwords, API keys or tokens: keys that look like credentials (secret, token, password, api_key, …) are rejected with 422 forbidden_key.
The preference object
{
"data": {
"namespace": "global",
"key": "appearance",
"value": "dark",
"version": 1042,
"updated_at": "2026-11-06T10:00:00.000000Z",
"updated_by": {"type": "user", "client_id": "9f0c…"}
}
}updated_by.type is user (written with a user token; client_id is the app it was issued to) or app. Lists are {"data": [...]} sorted by key, without pagination. Single preferences send ETag: "<version>".
Values round-trip exactly: {} stays an object, " x " and "" stay as sent.
The user's own preferences (user token)
| Method | Path | Scope |
|---|---|---|
GET | /api/v1/preferences/{namespace} | preferences or preferences:read |
GET | /api/v1/preferences/{namespace}/{key} | preferences or preferences:read |
PUT | /api/v1/preferences/{namespace}/{key} {"value": …} | preferences |
DELETE | /api/v1/preferences/{namespace}/{key} | preferences |
User tokens reach global only; an app namespace answers 404 namespace_not_found, whatever the token's scopes. An app token has no user and gets 401 here.
curl -X PUT https://id.anikeen.com/api/v1/preferences/global/appearance \
-H 'Authorization: Bearer USER_TOKEN' \
-H 'Accept: application/json' -H 'Content-Type: application/json' \
-d '{"value": "dark"}'use Anikeen\Id\Enums\Appearance;
use Anikeen\Id\Enums\SharedPreference;
$user->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM); // default when not set
$user->globalPreferences()->values(); // ['appearance' => 'dark']
$preference = $user->setGlobalPreference(SharedPreference::APPEARANCE, Appearance::DARK);
$preference->version;
$user->deleteGlobalPreference(SharedPreference::APPEARANCE);Preferences of any user (trusted apps)
With the app's own token, by the user's Anikeen ID:
| Method | Path | Who |
|---|---|---|
GET | /api/v1/client/users/{user}/preferences/{namespace} | owners, read access, global apps |
GET | /api/v1/client/users/{user}/preferences/{namespace}/{key} | same |
PUT | /api/v1/client/users/{user}/preferences/{namespace}/{key} {"value": …} | owners, global apps |
DELETE | /api/v1/client/users/{user}/preferences/{namespace}/{key} | same |
The user is looked up after the app and namespace checks, so an app without access gets the same answer for any user ID. Deleted users answer 404 user_not_found.
$anikeenId = app(\Anikeen\Id\AnikeenId::class);
$preferences = $anikeenId->preferencesByUserId($userId, 'acme'); // sorted by key, no pagination
$preferences->get('onboarding', []); // the value or a default; also has(), find(), values()
$preference = $anikeenId->preferenceByUserId($userId, 'acme', 'onboarding'); // 404 preference_not_found if not set
$preference->value; // JSON objects as stdClass, so {} stays {}
$anikeenId->setPreferenceByUserId($userId, 'acme', 'onboarding', ['step' => 3]);
$anikeenId->deletePreferenceByUserId($userId, 'acme', 'onboarding');A value is any JSON value except null (422 value_required: delete the key instead). In PHP, pass (object) [] for an empty object ([] is an empty list). setPreferenceByUserId() returns the stored preference; wasCreated() tells whether the write created the key.
Writes and versions
PUTanswers201when it created the key,200when it changed it or the value was already stored.DELETEanswers204.- Writes are last write wins by default. Every write gives the preference a new
version. Versions are opaque: they only go up (also when a key is deleted and set again) and increase per user, not by one per key. - Writing the value that is already stored is a no-op that succeeds (no new version), even when a precondition would fail – retrying a write after a timeout is safe.
Conditional writes
| Header | On | Writes only if | Fails with |
|---|---|---|---|
If-Match: "<version>" | PUT, DELETE | the stored version is this one | 412 preference_version_mismatch |
If-None-Match: * | PUT | the key isn't set yet | 412 preference_exists |
Both failures return current_version (null when the key isn't set): reload, merge, retry.
use Anikeen\Id\Exceptions\PreconditionFailedException;
$preference = $anikeenId->preferenceByUserId($userId, 'acme', 'layout');
try {
$anikeenId->setPreferenceByUserId($userId, 'acme', 'layout', $merged, ifVersion: $preference->version);
} catch (PreconditionFailedException $e) {
$e->currentVersion(); // reload, merge, retry
}
$anikeenId->setPreferenceByUserId($userId, 'acme', 'welcome_sent_at', now()->toIso8601String(), createOnly: true);Unique keys and the lookup
Anikeen can declare keys of your namespace unique, e.g. customer_id. Their values are strings of 1 to 255 characters without surrounding whitespace, held by at most one user: setting another user's value fails with 409 preference_value_taken. Owners can then find the user by value, e.g. in a payment provider's webhook:
POST /api/v1/client/preferences/{namespace}/lookup
Authorization: Bearer APP_TOKEN
{"key": "customer_id", "value": "cus_123"}{"data": {"user_id": "01j…", "namespace": "acme", "key": "customer_id", "version": 77, "updated_at": "…"}}The value is sent in the body, never in the URL, and never returned. Not found (also a deleted user): 404 preference_not_found. A key that isn't declared unique: 422 key_not_unique.
$userId = $anikeenId->findUserIdByPreference('acme', 'customer_id', 'cus_123'); // null if nobody holds itErrors
Every error is {"message", "error"} (422 also errors by field). The checks run in a fixed order, so a caller learns nothing it may not know.
| Status | error |
|---|---|
| 401 | unauthenticated (no or an invalid token; an app token on the user routes) |
| 403 | missing_scope, app_token_required (a user token on the app routes), client_not_trusted, namespace_read_only, lookup_not_allowed |
| 404 | namespace_not_found (unknown or not accessible), user_not_found (also deleted users), preference_not_found, not_found |
| 405 | method_not_allowed |
| 409 | preference_value_taken |
| 412 | preference_version_mismatch, preference_exists |
| 422 | invalid_key, forbidden_key, unknown_shared_key, value_required, value_too_large, value_too_deep, invalid_shared_value, invalid_unique_value, key_not_unique, preference_limit_exceeded |
| 429 | rate_limited |
Caching
Anikeen ID is the source of truth. For a copy that outlives the request, keep a read cache: fill it at sign-in, write to Anikeen ID first and update the copy from the response, and let it expire after about an hour, since other apps and the user can change shared preferences too. Trusted apps can subscribe to preference.updated to drop it sooner.
$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(),
fn () => $user->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM));When a user deletes their account, their preferences are deleted with it (and unique values are free for other users again).