Skip to content

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:

  • global is shared by all apps. Only registered keys and values are accepted: today appearance with system, light or dark.
  • 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.

Callerglobalown app namespacenamespace with read accessother or unknown
user token (any app)read, write404404404
app token of a trusted appread, write if registered for global, else 404read, write, lookupread only404
app token of an untrusted app403403403403

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,
  • global with the app's token (users always read and write their own global with 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_limited with Retry-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 ​

json
{
  "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) ​

MethodPathScope
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.

shell
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"}'
php
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:

MethodPathWho
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.

php
$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 ​

  • PUT answers 201 when it created the key, 200 when it changed it or the value was already stored. DELETE answers 204.
  • 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 ​

HeaderOnWrites only ifFails with
If-Match: "<version>"PUT, DELETEthe stored version is this one412 preference_version_mismatch
If-None-Match: *PUTthe key isn't set yet412 preference_exists

Both failures return current_version (null when the key isn't set): reload, merge, retry.

php
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:

http
POST /api/v1/client/preferences/{namespace}/lookup
Authorization: Bearer APP_TOKEN

{"key": "customer_id", "value": "cus_123"}
json
{"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.

php
$userId = $anikeenId->findUserIdByPreference('acme', 'customer_id', 'cus_123'); // null if nobody holds it

Errors ​

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.

Statuserror
401unauthenticated (no or an invalid token; an app token on the user routes)
403missing_scope, app_token_required (a user token on the app routes), client_not_trusted, namespace_read_only, lookup_not_allowed
404namespace_not_found (unknown or not accessible), user_not_found (also deleted users), preference_not_found, not_found
405method_not_allowed
409preference_value_taken
412preference_version_mismatch, preference_exists
422invalid_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
429rate_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.

php
$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).

Anikeen ID developer documentation