# Anikeen ID [![Latest Stable Version](https://img.shields.io/packagist/v/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![Total Downloads](https://img.shields.io/packagist/dt/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) [![License](https://img.shields.io/packagist/l/anikeen/id.svg?style=flat-square)](https://packagist.org/packages/anikeen/id) PHP Anikeen ID API Client for Laravel 12+ Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders, subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID. ## Table of contents 1. [Installation](#installation) 2. [Configuration](#configuration) 3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id) 4. [Payments](#payments) 5. [Trusted apps](#trusted-apps) 6. [User profiles](#user-profiles) 7. [Preferences](#preferences) 8. [API authentication](#api-authentication) 9. [General](#general) 10. [Examples](#examples) 11. [Documentation](#documentation) 12. [Development](#development) ## Installation ``` composer require anikeen/id ``` ## Configuration Register your app in Anikeen ID first. You need: - an OAuth client with your callback URL as redirect URI (e.g. `https://shop.test/auth/callback`), - a webhook endpoint (e.g. `https://shop.test/webhooks/anikeen`) if you sell something; Anikeen ID shows its signing secret `whsec_…`, - optionally a logo, shown to your users on the login and confirmation pages. Add the credentials to your `.env` file: ``` ANIKEEN_ID_KEY= ANIKEEN_ID_SECRET= ANIKEEN_ID_CALLBACK_URL=http://localhost/auth/callback ANIKEEN_ID_WEBHOOK_SECRET= ``` To switch from `production` to `staging` use following variable: ``` ANIKEEN_ID_MODE=staging ``` You will need to add an entry to the services configuration file so that after config files are cached for usage in production environment (Laravel command `artisan config:cache`) all config is still available. Add to `config/services.php` file: ```php 'anikeen' => [ 'mode' => env('ANIKEEN_ID_MODE'), 'client_id' => env('ANIKEEN_ID_KEY'), 'client_secret' => env('ANIKEEN_ID_SECRET'), 'redirect' => env('ANIKEEN_ID_CALLBACK_URL'), 'base_url' => env('ANIKEEN_ID_BASE_URL'), 'webhook_secret' => env('ANIKEEN_ID_WEBHOOK_SECRET'), ], ``` ## Sign in with Anikeen ID ### Event Listener Since Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method: ```php public function boot(): void { Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) { $event->extendSocialite('anikeen', \Anikeen\Id\Socialite\Provider::class); }); } ``` ### User model Your users table needs the user's Anikeen ID and their tokens: ```php Schema::table('users', function (Blueprint $table) { $table->string('anikeen_id')->nullable()->unique(); $table->text('anikeen_id_access_token')->nullable(); $table->text('anikeen_id_refresh_token')->nullable(); }); ``` Add the `Billable` trait to the user model. Keep the tokens encrypted and out of JSON: ```php use Anikeen\Id\Billable; class User extends Authenticatable { use Billable; protected $hidden = ['password', 'remember_token', 'anikeen_id_access_token', 'anikeen_id_refresh_token']; protected function casts(): array { return [ 'anikeen_id_access_token' => 'encrypted', 'anikeen_id_refresh_token' => 'encrypted', ]; } } ``` All user methods (`$user->orders()`, `$user->subscriptions()`, …) call the API with the user's access token from `anikeen_id_access_token`. ### Login and callback Ask for the scopes your app needs (see [Scope](src/Id/Enums/Scope.php)). To sell orders and subscriptions, a shop typically asks for: ```php use Laravel\Socialite\Facades\Socialite; Route::get('auth/redirect', fn () => Socialite::driver('anikeen') ->scopes(['user:read', 'addresses:read', 'orders', 'subscriptions']) ->redirect()); Route::get('auth/callback', function () { $account = Socialite::driver('anikeen')->user(); $user = User::updateOrCreate(['anikeen_id' => $account->getId()], [ 'name' => $account->getName() ?: $account->getNickname(), 'email' => $account->getEmail(), 'anikeen_id_access_token' => $account->token, 'anikeen_id_refresh_token' => $account->refreshToken, ]); Auth::login($user, remember: true); return redirect()->intended('/'); }); ``` Access tokens expire. Get a new one with the refresh token (or send the user through the login again): ```php $result = app(\Anikeen\Id\AnikeenId::class)->refreshToken($user->anikeen_id_refresh_token); if ($result->success()) { $user->update([ 'anikeen_id_access_token' => $result->data()->access_token, 'anikeen_id_refresh_token' => $result->data()->refresh_token, ]); } ``` ### Scopes Scopes follow one schema: `resource` grants full access, `resource:read` read-only access and `resource:client` access to what the app or token created itself. All of them are constants on `Anikeen\Id\Enums\Scope`, e.g. `Scope::ORDERS_READ`. The Anikeen Console accepts personal access tokens and OAuth tokens (no client credentials) with these scopes for its product and reseller APIs: | Scope | Constant | Grants | |-------|----------|--------| | `products:read` | `Scope::PRODUCTS_READ` | view the products of your teams | | `reseller-customers:read` | `Scope::RESELLER_CUSTOMERS_READ` | view a reseller team's customers and invitations | | `reseller-customers` | `Scope::RESELLER_CUSTOMERS` | manage customers: invite, resend, remove logins (includes reading) | | `reseller-grants` | `Scope::RESELLER_GRANTS` | grant customers access to products and revoke it | | `reseller-orders` | `Scope::RESELLER_ORDERS` | order products for customers, charged right away | A full reseller scope includes reading. `user` and `*` do not open the reseller endpoints: the token has to carry the reseller scope itself. For the product APIs there is only `products:read`; changing products needs `user`. Changing the user's [profile](#user-profiles) needs `Scope::USER` (`user`); `user:read` only reads. Anikeen ID's [preferences](#preferences) use these scopes: | Scope | Constant | Grants | |-------|----------|--------| | `preferences:read` | `Scope::PREFERENCES_READ` | read the user's shared preferences (`global`) | | `preferences` | `Scope::PREFERENCES` | read and change them | | `preferences:client` | `Scope::PREFERENCES_CLIENT` | app tokens: the preferences of any user, in the namespaces Anikeen registered for the app | ### Change the default access token / refresh token field name If your access / refresh token fields differ from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method: ```php use Anikeen\Id\AnikeenId; public function boot(): void { AnikeenId::useAccessTokenField('anikeen_id_access_token'); AnikeenId::useRefreshTokenField('anikeen_id_refresh_token'); } ``` ## Payments ### Which way fits? | You want to sell | Use | Token | Who starts the payment | |---|---|---|---| | One-off purchases (digital or physical goods) | [Orders](#selling-an-order) | user | the user, on Anikeen ID | | Something that recurs (a pass, a plan) | [Subscriptions](#subscriptions) | user | the user, on Anikeen ID | | Several subscriptions in one step | [Checkout sessions](#checkout-sessions-the-user-confirms-several-subscriptions-at-once) | user or app | the user, on Anikeen ID | | Usage billing from your backend, without the user online | [App subscriptions](#app-subscriptions-trusted-apps-no-user-token) | app | your app (trusted apps only) | | Single items on the user's monthly bill (e.g. a shipped item) | [Charges](#charges-and-billing-limit-trusted-apps) | app | your app (trusted apps only) | Most apps are third-party apps: they create an order or subscription as a draft, send the user to Anikeen ID to confirm and pay, and get the user back afterwards. Anikeen ID shows the items, asks for missing addresses, takes the payment and writes the invoice. Trusted apps (operated by Anikeen) can also charge directly; see [Trusted apps](#trusted-apps). For a per-item charge on the user's monthly bill use `createChargeForUser()`, not `createOrderForUser()`: an order is a separate one-off purchase, charged on its own. The flow, as in a typical shop: 1. The user signs in with Anikeen ID ([above](#sign-in-with-anikeen-id)). 2. Your app creates the order: `$user->orders()->create([...])`. 3. Redirect the user to `$order->confirmationUrl($returnUrl)`. 4. The user confirms and pays on Anikeen ID, then comes back to `$returnUrl?status=…&order=…`. 5. Your app reads the order through the API (never trust the query string) and shows the result. 6. The `order.completed` webhook arrives; fulfil the order there. ### Selling an order ```php use Anikeen\Id\Exceptions\ResourceException; $order = $user->orders()->create([ 'billing_address_id' => $user->getUserData()->billing_address_id, 'items' => [ ['type' => 'digital', 'name' => 'Sticker pack', 'unit' => 'piece', 'units' => 1, 'gross_price' => '9.99'], ], ]); $shopOrder = ShopOrder::create(['user_id' => $user->id, 'anikeen_id' => $order->id, 'status' => $order->status]); // your own order model return redirect()->away($order->confirmationUrl(route('orders.return', $shopOrder))); // later: https://shop.test/orders/1/return?status=completed&order=01J… ``` When the user has no billing address on Anikeen ID yet, pass one instead of the ID. A name and the country are enough; the user completes it on the confirmation page: ```php 'billing_address' => ['first_name' => 'Ada', 'last_name' => 'Lovelace', 'country_iso' => 'DE', 'email' => $user->email], ``` Back in your app, read the order to know its state: ```php Route::get('orders/{shopOrder}/return', function (ShopOrder $shopOrder) { $order = auth()->user()->orders()->find($shopOrder->anikeen_id); $shopOrder->update(['status' => $order->status]); return redirect()->route('orders.show', $shopOrder); }); ``` The order's `status`: | `status` | Meaning | |---|---| | `draft` | Created, not confirmed by the user yet | | `pending` | Confirmed, the payment is still processing | | `completed` | Paid | | `cancelled` | The user cancelled, or it wasn't confirmed within 24 hours | `return_url` must have the origin of one of your app's redirect URIs. The `status` in its query string (`completed`, `pending` or `cancelled`) is only a hint for the message you show. `checkout()` on such a draft throws `ConfirmationRequiredException`; its `confirmationUrl` points to the same page. Trusted apps keep checking out directly. ### Prices and VAT Anikeen ID calculates VAT from the user's billing address and the item `type`: | `type` | For | VAT in Germany | |---|---|---| | `digital` | Downloads, streams, services | 19 % | | `physical` | Goods you ship | 19 % | | `physical-reduced` | Goods with the reduced rate (books, event tickets, …) | 7 % | Give each item either `gross_price` (VAT included, what the user pays) or `price` (net), per unit. `unit` is `piece` or `onetime` for orders; `units` is the quantity. Subscription prices are always net per unit (`hour`, `day`, `week`, `month`, `year`). To show a total before the user confirms, ask for a preview: ```php $preview = app(\Anikeen\Id\AnikeenId::class)->createOrderPreview([ 'country_iso' => 'DE', 'items' => [['type' => 'physical', 'name' => 'T-shirt', 'gross_price' => '24.90', 'unit' => 'piece', 'units' => 1]], ])->shift(); $preview->gross_total; ``` ### Physical goods and shipments Physical goods need a shipping address (`first_name`, `last_name`, `address`, `postal_code`, `city`, `country_iso`). The user can change both addresses on the confirmation page, so read the shipping address from the order (or the `order.completed` webhook) before you ship. An item's `image_url` is copied to Anikeen ID and stays visible on the order and invoice: ```php $order = $user->orders()->create([ 'billing_address_id' => $addressId, 'shipping_address' => ['first_name' => 'Ada', 'last_name' => 'Lovelace', 'address' => 'Main St', 'house_number' => '1', 'postal_code' => '10115', 'city' => 'Berlin', 'country_iso' => 'DE'], 'items' => [ ['type' => 'physical', 'name' => 'T-shirt', 'unit' => 'piece', 'units' => 1, 'gross_price' => '24.90', 'image_url' => 'https://shop.test/img/shirt.png'], ['type' => 'physical-reduced', 'name' => 'Artbook', 'unit' => 'piece', 'units' => 1, 'gross_price' => '19.95'], ], ]); ``` Once a paid order is sent, create a shipment. Anikeen ID adds its delivery note to the user's order documents (with the tracking link), mails it to the user (`'notify_customer' => false` if your shop sends its own mail) and sends `order.shipped` to your webhook endpoints. Without `items`, everything physical that is left is shipped; partial deliveries name the items: ```php $order = $user->orders()->find($orderId); $shipment = $order->shipments()->create([ 'carrier' => 'DHL', 'tracking_number' => '00340434161234567890', 'tracking_url' => 'https://www.dhl.de/…?piececode=00340434161234567890', // 'items' => [['order_item_id' => $itemId, 'units' => 1]], ]); $shipment->number; // LS-2026-0001 $shipment->deliveryNote(); // the delivery note as PDF ``` To know what is left to ship, compare the order's items with its shipments: ```php $items = $order->orderItems()->toArray(); // id, type, name, units, … $shipments = $order->shipments()->toArray(); // each with items: [{order_item_id, units}] ``` Only the app that created the order (or a trusted app) can ship it, and only once it is paid. ### Subscriptions Subscriptions work like orders: create a draft, let the user confirm it, read it back. ```php $subscription = $user->subscriptions()->create([ 'name' => 'Pixel Stream Pass', 'description' => 'All streams, every month', 'unit' => 'month', 'price' => 6.714286, // net per unit, 7.99 with 19 % VAT ]); return redirect()->away($subscription->confirmationUrl(route('pass.return'))); // later: https://shop.test/pass/return?status=completed&subscription=01J… ``` After the return, read the subscription and check its state: ```php $subscription = $user->subscriptions()->find($id); if ($subscription->isActive()) { // grant access } ``` `status()` returns one of the `Anikeen\Id\Enums\SubscriptionStatus` constants (the API sends the same value as `status`): | `status()` | Meaning | `isActive()` | |---|---|---| | `draft` | Created, not confirmed by the user yet | no | | `trial` | Running, in its trial period | yes | | `active` | Running and billed | yes | | `paused` | Runs until the paid period ends, then expires (`resume()` to continue) | yes | | `cancelling` | Runs until a scheduled cancellation takes effect | yes | | `expired` | Paused and ran out; `resume()` starts a new period | no | | `cancelled` | Cancelled for good | no | There are shortcuts for each: `isDraft()`, `isTrial()`, `isPaused()`, `isCancelling()`, `isExpired()`, `isCancelled()`. App subscriptions (`appSubscriptions()`) have the same methods. #### Free trials Give a subscription a free trial with `trial_days` (1–365, counted from the user's confirmation or the checkout) or a fixed `trial_ends_at` (ISO 8601, in the future), not both. The subscription starts right away with `status` `trial` and nothing billed; the first period is billed when the trial ends. `trial_once` refuses the trial if the user already had one with your app in the same `group` (422 when creating, 409 `trial_already_used` when checking out): ```php $subscription = $user->subscriptions()->create([ 'name' => 'Pixel Stream Pass', 'unit' => 'month', 'price' => 6.714286, 'trial_days' => 14, 'trial_once' => true, ]); ``` A few days before the trial ends, Anikeen ID sends `subscription.trial_will_end` (`EventType::SUBSCRIPTION_TRIAL_WILL_END`), e.g. to remind the user; `data.payment_method_missing` tells you whether the user still has to add a payment method. Cancelling during the trial bills nothing. Trials work the same for app subscriptions and per subscription of a checkout session. By default the user needs a payment method to confirm a trial. With `trial_payment_method` `TrialPaymentMethod::OPTIONAL` (only together with a trial; `TrialPaymentMethod::REQUIRED` is the default) the user can confirm it without one, as long as nothing else has to be paid: in a checkout session every subscription has to be such a trial. The confirmation page tells the user to add a payment method before the trial ends. If there is still none then, the subscription is **cancelled** at the trial's end instead of billed: `status` `cancelled`, `cancellation_reason` `CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD`, `subscription.cancelled` with `data.reason` set to the same value, and no `subscription.renewed`. Adding a payment method later doesn't bring it back – create a new subscription. The trial still counts for `trial_once`. Anikeen ID also mails the user a few days before such a trial ends without a payment method. ```php use Anikeen\Id\Enums\CancellationReason; use Anikeen\Id\Enums\TrialPaymentMethod; use Anikeen\Id\Webhooks\EventType; $subscription = $user->appSubscriptions()->create([ 'group' => $teamId, 'name' => 'Uptimr Pro', 'unit' => 'month', 'price' => 19.00, 'trial_days' => 14, 'trial_payment_method' => TrialPaymentMethod::OPTIONAL, ]); // in your webhook handler $data = $event['data']; match ($event['type']) { EventType::SUBSCRIPTION_TRIAL_WILL_END => $data['payment_method_missing'] ? Reminder::addPaymentMethod($data['object']['id']) : null, EventType::SUBSCRIPTION_CANCELLED => $data['reason'] === CancellationReason::TRIAL_ENDED_WITHOUT_PAYMENT_METHOD ? Pass::downgrade($data['object']['id']) : Pass::revoke($data['object']['id']), default => null, }; ``` #### Discounts A `discount` (e.g. a coupon) lowers the price of billed periods: every discounted period gets a negative line with the `label` next to its usage on the same bill, until the discount ends. ```php $subscription = $user->subscriptions()->create([ 'name' => 'Pixel Stream Pass', 'unit' => 'month', 'price' => 6.714286, 'discount' => [ 'label' => 'SUMMER20', // shown to the user, e.g. the coupon code 'percent_off' => 20, // or 'amount_off' => 1.50 (net, per period) 'duration' => 'repeating', // once, repeating or forever 'duration_in_periods' => 3, // only for repeating ], ]); $subscription->discount; // {label, percent_off, amount_off, duration, duration_in_periods, periods_remaining} ``` A period is one unit of the subscription (a month for monthly prices). A free trial isn't a billed period: the discount starts with the first paid one. `periods_remaining` counts down with every billed period (null for `forever`, 0 once it ended); then `subscription.discount_ended` (`EventType::SUBSCRIPTION_DISCOUNT_ENDED`) is sent. A discount can't be combined with a `monthly_cap`. It is set when the subscription is created (app subscriptions and checkout sessions alike) and changed afterwards with [`change()`](#changing-running-subscriptions-trusted-apps). A resize (`predecessor_id`) doesn't carry it over: send the successor the rest (`'duration' => 'repeating'`, `'duration_in_periods' => $old->discount->periods_remaining`). ### Webhooks Anikeen ID sends `order.*`, `subscription.*` and `checkout.*` events to the webhook endpoints of your app (see [`EventType`](src/Id/Webhooks/EventType.php) for all of them). Verify each request with the raw body and answer 2xx within 10 seconds; failed deliveries are retried for about three days. Exclude the route from CSRF protection in `bootstrap/app.php`: ```php ->withMiddleware(function (Middleware $middleware) { $middleware->validateCsrfTokens(except: ['webhooks/*']); }) ``` ```php use Anikeen\Id\Exceptions\SignatureVerificationException; use Anikeen\Id\Webhooks\EventType; use Anikeen\Id\Webhooks\Webhook; Route::post('webhooks/anikeen', function (Request $request) { try { $event = Webhook::constructEvent($request->getContent(), $request->header('Anikeen-Signature'), config('services.anikeen.webhook_secret')); } catch (SignatureVerificationException) { abort(400); } if (! ProcessedEvent::firstOrCreate(['id' => $event['id']])->wasRecentlyCreated) { return response()->noContent(); // already handled } $object = $event['data']['object']; match ($event['type']) { EventType::ORDER_COMPLETED => Shop::fulfil($object['id'], $object['shipping_address']), EventType::ORDER_CANCELLED => Shop::release($object['id']), EventType::SUBSCRIPTION_ACTIVATED => Pass::grant($object['id']), EventType::SUBSCRIPTION_CANCELLED => Pass::revoke($object['id']), EventType::CHECKOUT_COMPLETED => Panel::provision($object['subscription_ids']), default => null, }; return response()->noContent(); }); ``` `data.object` is the order, subscription or checkout session as the API returns it. Events can arrive more than once or out of order: use `$event['id']` to skip duplicates, and reload the object through the API when the current state matters. The admin panel can send a `webhook.test` event to check your endpoint. `subscription.renewed` (`EventType::SUBSCRIPTION_RENEWED`) is sent once for every period booked on the user's monthly order, **the first one included**, with `$event['data']['period']`: `{subscription_id, period_start, period_end, periods, first_period, unit, order_id, order_item_id, items, currency_iso, net_amount}` (`net_amount` after a discount). `first_period` is `true` for the subscription's first paid period (the checkout, sent after `subscription.activated`, or the first period after a free trial) and `false` for every later one (renewals, resuming an expired subscription, a unit change applied right away). Hourly subscriptions renew every hour, so this event is **opt-in**: select it explicitly for your endpoint, `*` doesn't include it. ```php if ($event['type'] === EventType::SUBSCRIPTION_RENEWED) { $period = $event['data']['period']; // e.g. a commission per paid period; the first one may count differently Commission::record($period['subscription_id'], $period['net_amount'], first: $period['first_period']); } ``` `order.*` events list the order's `items` and `invoice` like [app orders](#app-orders-trusted-apps-no-user-token). `billing_limit.reached` and `billing_limit.available` (`EventType::BILLING_LIMIT_REACHED`, `BILLING_LIMIT_AVAILABLE`) tell trusted apps that a user's billing limit was reached or has room again; they are opt-in like `subscription.renewed`, see [Charges and billing limit](#charges-and-billing-limit-trusted-apps). `order.refunded` (`EventType::ORDER_REFUNDED`) is sent when money was given back for an order (a refund or a partial cancellation, with a credit note); the order carries its `refunds` and `refunded_total`. A full refund also cancels the order, so `order.cancelled` is sent too; don't rely on the order of the two events. #### User and preference events These events are about the user rather than your app's orders. They are **opt-in**: select them explicitly for your endpoint, `*` doesn't include them. | Type | Constant | Sent to | `data` | |------|----------|---------|--------| | `user.updated` | `EventType::USER_UPDATED` | apps the user is connected to (untrusted apps only with a token of the user carrying `user`, `user:read` or `*`), the app that made the change included | `object`: the full profile `{object: "user", id, name, username, email, email_verified, avatar, version}`; `changed`: the changed fields, e.g. `["name", "avatar"]` (never old values) | | `user.deleted` | `EventType::USER_DELETED` | apps the user was connected to | `object`: `{object: "user", id, deleted: true, deleted_at}` | | `preference.updated` | `EventType::PREFERENCE_UPDATED` | [trusted](#trusted-apps) apps that may read the namespace, never the app that made the change | `object`: `{object: "preference", user_id, namespace, key, version, deleted, updated_at, updated_by: {type: "user"\|"app", client_id}}`, never the value | ```json {"id": "01JEVT…", "type": "user.updated", "created": 1762500000, "data": {"object": {"object": "user", "id": "01JUSER…", "name": "Erika Mustermann", "username": "erika", "email": "erika@example.com", "email_verified": true, "avatar": "https://id.anikeen.com/avatars/01JUSER…", "version": 8}, "changed": ["name", "avatar"]}} ``` - **`user.updated`** is sent for changes to `name`, `username`, the verified email address, `email_verified` and the profile picture, from any source (the user's settings, the [profile API](#changing-the-profile-user-token), support). **A new email address arrives only once it is verified**: `email` is `null` while the address isn't, and a requested change sends nothing until the user confirms it (then `changed` is `["email", "email_verified"]` or `["email"]`). `avatar` is a stable URL; `avatar` in `changed` means the picture behind it changed: drop a cached copy. `new User($object)` (`Anikeen\Id\Resources\User`) reads the object. - **Versions:** `version` grows with every such change (`profile_version` in the REST shapes). Events can arrive late or out of order: keep the highest version you have seen and ignore older ones. A missed event can be caught up with [`findUserById()`](#reading-a-user-by-id-trusted-apps). - **`user.deleted`** is final and sent once: delete or anonymise your copy of the account (and what you keep only for it), and stop acting for the user. No other event about the user follows it, also none still queued. If a `user.updated` arrives later for the same ID, support restored the account: treat it as a returning user. - **`preference.updated`** is thin: fetch the preference again (`preferenceByUserId()`) or drop your copy when `deleted` is `true`, and ignore events with a lower `version` than the one you hold (versions are per user). Nothing is sent for a write that changes nothing, and none when the account is deleted (`user.deleted` covers that). ```php use Anikeen\Id\Resources\User; $object = $event['data']['object']; if ($event['type'] === EventType::USER_UPDATED) { $profile = new User($object); $account = Account::firstWhere('anikeen_id', $profile->id()); if ($account && $profile->version() > $account->profile_version) { $account->update([ 'name' => $profile->name(), 'email' => $profile->email() ?? $account->email, // null while unverified 'profile_version' => $profile->version(), ]); } } if ($event['type'] === EventType::USER_DELETED) { Account::firstWhere('anikeen_id', $object['id'])?->anonymise(); } if ($event['type'] === EventType::PREFERENCE_UPDATED) { Cache::forget("preferences:{$object['user_id']}"); } ``` ### Errors Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as well as with the app's (`app*()`). It carries: - `status`: the HTTP status (also `getCode()`), `0` when Anikeen ID could not be reached, - `error`: a stable error code when Anikeen ID sent one (e.g. `billing_address_missing`, `already_cancelled`), otherwise `null`, - `getMessage()`: Anikeen ID's reason, - `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry later), - `isBillingLimitReached()` (409 `billing_limit_reached`: a [charge](#charges-and-billing-limit-trusted-apps) would take the user's monthly bill above their billing limit), - `body()`: the error response, for errors with details (e.g. `remaining` of `billing_limit_reached`). Refused input (422) throws `ValidationFailedException`, a subclass of `ApiException`. Endpoints with codes per field (the [profile API](#changing-the-profile-user-token)) fill `errorCodes()` (`['username' => 'username_taken']`), `errorCodeFor($field)` and `hasErrorCode($code)`; `errors()` has the messages per field. ```php use Anikeen\Id\Exceptions\ApiException; try { $order = $user->orders()->create($attributes); } catch (ApiException $e) { if ($e->isTemporary()) { return back()->with('error', 'Anikeen ID is not reachable right now, please try again.'); } report($e); return back()->with('error', $e->getMessage()); } ``` Failed lists (`$user->invoices()`, …) throw `CollectionException`, a subclass of `ApiException`. `ConfirmationRequiredException` (a third-party app called `checkout()`, see above) stays its own type. All of them extend `ResourceException`, so existing `catch (ResourceException $e)` blocks keep working. ### Testing Develop against staging (`ANIKEEN_ID_MODE=staging`) with an OAuth client registered there. Payments on staging run in Stripe's test mode: pay with Stripe's [test cards](https://docs.stripe.com/testing), e.g. `4242 4242 4242 4242` with any future expiry date and any CVC. No money is charged. Point `ANIKEEN_ID_BASE_URL` at your own Anikeen ID instance if you run one locally. In unit tests, swap the HTTP client for a Guzzle `MockHandler`: ```php $mock = new \GuzzleHttp\Handler\MockHandler([ new \GuzzleHttp\Psr7\Response(201, ['Content-Type' => 'application/json'], json_encode(['data' => ['id' => '01JORDER', 'status' => 'draft']])), ]); $this->app->instance(AnikeenId::class, (new AnikeenId())->setHttpClient(new \GuzzleHttp\Client([ 'handler' => \GuzzleHttp\HandlerStack::create($mock), 'base_uri' => 'https://id.test/api/', ]))); ``` ## Trusted apps Apps operated by Anikeen can be marked as trusted. They can charge without asking the user and use their own token (client credentials), so billing keeps working when the user's login expired. ### App subscriptions (trusted apps, no user token) `subscriptions()` acts with the user's own access token and stops working when the user's login expired. Trusted apps can manage a user's subscriptions with the app's own token (client credentials, scope `subscriptions:client`) instead: ```php $subscriptions = $team->owner->appSubscriptions(); // nothing is requested yet $subscription = $subscriptions->create([ 'group' => $team->id, 'name' => 'Cloud Server S', 'unit' => 'hour', 'price' => 0.0137, 'idempotency_key' => "server:{$pendingServer->id}", // retries return the same subscription ]); $subscription->checkout(); // bill the first period $subscription->checkout(startsAt: $domain->registered_at); // already running: bill the period that started then $subscription->checkout( // running for longer: also bill the periods that have ended startsAt: $domain->registered_at, billElapsedPeriods: true, maxNetAmount: '15.57', // net ceiling, e.g. the amount the customer agreed to ); $subscription->pause(); // expires when the paid period ends $subscription->resume(); // expired subscriptions start a new period $subscription->revoke(refund: false); // cancel $subscriptions->find($id); // null if unknown or not this user's $subscriptions->all(['group' => $team->id, 'active' => true]); ``` The user's Anikeen ID is read from the billable's `anikeen_id` attribute (`AnikeenId::useUserIdField('…')` to change it). The app must be trusted by Anikeen ID; it sees the subscriptions it created (plus those of clients delegated to it). #### Starting from an earlier date `checkout(startsAt: …)` books a service that is already running, e.g. a domain registered before the customer came to your app. If the first period from that date is still running, it is billed now and renews when it ends. An older start is refused with `period_already_ended`, unless you pass `billElapsedPeriods: true` with `maxNetAmount` (net, up to two decimals; both together, or `checkout()` throws an `InvalidArgumentException` without sending anything): - every period that has ended plus the running one is billed now, as one line with the full service period. It lands on the user's collective order of the current month and is invoiced and charged with the next monthly bill; nothing is back-dated. - the start may be at most about three years ago (422 otherwise). - if the periods come to more than `maxNetAmount`, nothing is booked: 409 `elapsed_amount_exceeded`, with the real amount in `$e->body()->net_amount` (e.g. `"20.76"`), so you can show it and retry. - 409 `elapsed_periods_not_allowed` when the subscription can't be billed this way (e.g. it ran before, replaces another one, or is billed by the hour or day with a monthly cap). ```php try { $subscription->checkout(startsAt: $startsAt, billElapsedPeriods: true, maxNetAmount: $confirmed); } catch (ApiException $e) { if ($e->error === 'elapsed_amount_exceeded') { $actual = $e->body()->net_amount; // ask again with the real amount } else { throw $e; } } ``` #### Error codes Failures throw `Anikeen\Id\Exceptions\ApiException` with `status` (0 = not reachable), a stable `error` code (`already_active`, `already_cancelled`, `not_active`, `already_paused`, `already_resumed`, `billing_address_missing`, `not_changeable`, `subscription_not_found`, `idempotency_key_conflict`, `in_checkout_session`, `trial_already_used`, `proration_not_possible`, `credit_exceeds_bill`, `period_already_ended`, `elapsed_periods_not_allowed`, `elapsed_amount_exceeded`), the error response via `body()` and `isNotFound()`, `isConflict()`, `isTemporary()`: ```php try { $subscription->revoke(); } catch (ApiException $e) { if ($e->isNotFound() || $e->error === 'already_cancelled') { // already gone } elseif ($e->isTemporary()) { // retry later } else { throw $e; } } ``` ### Confirmable orders (trusted apps) Trusted apps can charge one-off orders right away (`'checkout' => true`). With `'confirm' => true` the user confirms and pays the order on Anikeen ID instead, like for a third-party app – e.g. a top-up the user starts in your app: Anikeen ID shows the order with VAT, the billing address and the payment method (one can be added there) and takes the payment. ```php $order = app(AnikeenId::class)->createOrderForUser($team->owner->anikeen_id, [ 'group' => $team->id, 'confirm' => true, 'return_url' => route('billing.topup.done'), // same origin as one of your redirect URIs 'metadata' => ['topup' => $topup->id], // returned as is, also in the order webhooks 'items' => [['type' => 'digital', 'name' => 'Credit', 'unit' => 'onetime', 'units' => 1, 'gross_price' => '10.00']], ])->data; return redirect()->away($order->confirmation_url); // later: https://console.test/billing/topup/done?status=completed&order=01K… ``` `confirm` needs `items` and can't be combined with `checkout` or a `transaction` (422). The draft expires after 24 hours (`expires_at`): it is cancelled, `confirmation_url` becomes null and `checkout()` answers 409 `confirmation_expired`. Follow the order through its webhooks: `order.created`, then `order.completed` once paid (`order.payment_failed` for a declined charge) or `order.cancelled` when the user declines or it expires. `return_url` and `metadata` work for every order, also with the user's token (`$user->orders()->create([...])`). ### Changing running subscriptions (trusted apps) `update()` changes a subscription before its checkout (afterwards only the description). `change()` changes price, unit, name, description or discount of a running subscription, like a plan change (send only what changes): ```php use Anikeen\Id\Resources\AppSubscription; // upgrade now, prorated: the unused rest of the period is credited, the new price charged for it $subscription->change(['apply' => AppSubscription::APPLY_NOW, 'price' => '0.0274', 'name' => 'Cloud Server M']); // downgrade from the next period on $subscription->change(['apply' => AppSubscription::APPLY_PERIOD_END, 'price' => '4.99']); $subscription->scheduled_change; // {applies_at, name, description, unit, price, discount, created_at} $subscription->cancelScheduledChange(); // keep the current values after all ``` - `now` credits the unused part of the current period at the rate it was billed at and charges the rest at the new price, both on the user's open bill. A new `unit` starts a new, fully billed period now. A downgrade whose credit would exceed the open bill is refused with 409 `credit_exceeds_bill`: schedule it for the period end instead. 409 `proration_not_possible` when the current period wasn't billed to the subscription (e.g. a takeover); `period_end` works. - `period_end` changes nothing now; the next period is billed at the new values. A new `period_end` change replaces the scheduled one, `now` drops it. - During a free trial nothing was billed: both just change what the trial ends in. - `discount` replaces the discount from the next billed period on, `null` ends it. When the unit changes, a discount that counts in periods or an `amount_off` has to be sent again (422). - Subscriptions with a monthly cap change only with `period_end`. - Only running subscriptions can change: 409 `not_changeable` for drafts (use `update()`), paused, cancelling, expired and cancelled ones. When a change takes effect (right away or at the period end), Anikeen ID sends `subscription.updated` (`EventType::SUBSCRIPTION_UPDATED`) with the old values of the changed fields in `$event['data']['previous_attributes']`. Telling the user about a price increase (and getting consent where needed) is up to your app. ### App orders (trusted apps, no user token) Orders that bill the app's subscriptions, read with the app's token (scope `orders:client`): ```php $team->owner->appOrders()->pending(); // unpaid orders of one user, oldest first app(AnikeenId::class)->appOrders()->pending(); // of all users $team->owner->appOrders()->find($orderId); // null if unknown or not this user's ``` Each order has `status` (`processing` = current month, still collecting; `pending` = closed and not paid; `completed`; `cancelled`), the totals, `payment_attempts`, `next_payment_attempt_at` (null = no more automatic retries) and `last_payment_error`. Collective (monthly) orders are kept **per app**: your subscriptions are never billed on the same order as another app's, even if both use the same `group` (or none); clients delegated to your app bill on your app's orders. `$order->oauth_client_id` is the app that bills the order. Orders also list their lines and their invoice, e.g. to show a team's invoices or to attribute amounts (commissions) per subscription: ```php $order = app(AnikeenId::class)->appOrders()->find($orderId); foreach ($order->items as $item) { $item->subscription_id; // null for lines that bill no subscription $item->period_start; $item->period_end; // ISO 8601, the end is exclusive; null without a period $item->net_amount; // units × price, rounded to cents $item->discount; // {label, percent_off, amount_off} on a discount line, else null } $order->invoice; // null until paid, then {id, number, status, …, pdf_url} $pdf = app(AnikeenId::class)->appOrders()->invoicePdf($orderId); // the invoice PDF (binary) ``` The net amount actually billed for a subscription is the sum of `net_amount` of its lines (discount lines are negative). `invoicePdf()` throws `ApiException` with 404 `invoice_not_found` before the order is paid and 409 `invoice_not_ready` while the PDF is still being rendered (usually seconds after the payment; try again later). Legacy orders from before collective orders were kept per app (`oauth_client_id` null) can also bill other apps' subscriptions: there `items` lists only your lines (the totals are the whole order's), `invoice->pdf_url` is null and `invoicePdf()` throws `ApiException` with 403 `invoice_includes_other_apps`. ### Billing portal links (trusted apps) A "Manage billing" button for a user whose token your app doesn't have (e.g. the owner of a team your app bills): create a link to the user's billing pages on Anikeen ID with the app's token (scope `subscriptions:client` or `orders:client`). Like Stripe's customer portal, it works **without a login**: ```php Route::post('teams/{team}/billing', function (Team $team) { return redirect()->away($team->owner->appBillingPortalUrl([ 'return_url' => route('teams.show', $team), // "Back to "; same origin as one of your redirect URIs 'group' => $team->id, // only this group's subscriptions, orders and invoices 'flow' => 'invoices', // overview (default), payment_method, payment_method_update, // invoices, subscriptions or orders ])); }); app(AnikeenId::class)->createBillingPortalLinkForUser($userId, ['flow' => 'payment_method_update']); // {object: "billing_portal_link", url, user_id, flow, group, return_url, expires_at} ``` The link is **single use** and valid for about 15 minutes: create it when the user clicks and redirect right away; don't store, mail or reuse it (a second request with the same link, e.g. a reload, gets a 403). Opening it starts a portal session for that user that needs no login and signs nobody in: only the billing pages of the `flow`, only what bills your app's subscriptions (of the `group`), with "Back to *app*". The user's account settings, other apps and OAuth consent stay behind the normal login; signed in as another account, the portal still shows only the link's user. The session ends after an hour without activity or on "Back to *app*". `payment_method_update` leads straight to replacing the payment method (e.g. after a failed payment) and back to `return_url`. For the signed-in user with their own token, `$user->billingPortalUrl()` stays available. ### Charges and billing limit (trusted apps) Trusted apps can book single items onto a user's **open monthly bill** of the app, e.g. each item a shipping app sends: one line per charge, no subscription, charged with the bill on the 1st like every other line (invoice and VAT by Anikeen ID). Anikeen has to enable charging for your app and sets a maximum per charge; the user must be connected to your app (they authorised it, or your app bills them through subscriptions or orders). ```php $anikeen = app(AnikeenId::class); $charge = $anikeen->createChargeForUser($user->anikeen_id, [ 'idempotency_key' => "shipment-{$shipment->id}", // per app and user: a retry returns the same charge 'name' => 'Signed card', // the line on the bill 'type' => 'physical', // digital (default), physical, physical-reduced 'quantity' => 2, 'unit_price' => '4.00', // net per piece, up to 6 decimals 'reference' => "order-{$shipment->order_id}", // your own id; not on the bill // 'group' => $team->id, // only a group your app already bills the user in // 'occurred_at' => '2026-11-05T14:00:00Z', // the service date, at most 31 days ago ]); $charge->id(); // keep it to void the charge later $charge->grossAmount(); // "9.52": what it added to the bill $charge->orderId(); // the monthly bill it is on $charge->status(); // open, billed (the bill was closed) or voided $anikeen->findChargeForUser($user->anikeen_id, $chargeId); $anikeen->voidChargeForUser($user->anikeen_id, $chargeId, 'Parcel lost'); // while the bill is open ``` - **Idempotency:** retrying with the same key and the same fields returns the existing charge (200 instead of 201), also after the limit was reached; the same key with other fields is 409 `idempotency_key_conflict` (`charge_id` in `body()`). Use one key per item you charge for. - **Voiding** removes the line while the bill is open (an already voided charge is returned as it is). Once the bill was closed: 409 `charge_already_billed`; only a refund of the order by Anikeen credits it. - **`group`** is null or a group your app already bills the user in (a running subscription or an open monthly bill of your app); others are refused with 409 `group_not_allowed`. - `reference` and `metadata` (up to 20 string values) are your data about the user, kept with the charge and not shown on the bill: put nothing sensitive there. **Billing limit.** A user can set one monthly limit (gross) for their whole open monthly bill across all Anikeen services. A charge that would take the bill above it is refused with 409 `billing_limit_reached`; exactly reaching the limit is allowed, `0.00` refuses every charge. Subscription renewals and usage are still billed, so the bill can end above the limit. Read the limit to stop offering paid actions early (advisory only, it reserves nothing): ```php $limit = $anikeen->billingLimitForUser($user->anikeen_id); $limit->hasLimit(); // false: no limit, every charge fits $limit->isReached(); // used >= limit: start no new paid service $limit->remaining(); // "17.60" gross room left, null without a limit $limit->remainingNet(); // "14.78" at the user's VAT rate, a hint $limit->allows('9.52'); // would this charge fit right now (advisory) $limit->resetsAt(); // when this month's bills are closed $limit->version(); // only goes up $limit->used(); // the user's open bills across all Anikeen services, null without a limit $limit->usedByApp(); // your app's share; used − usedByApp = "your other Anikeen services" ``` `used` (the user's open bills across all Anikeen services) is visible to apps **only while the user has set a limit**; without one `limit`, `used`, `remaining` and `remaining_net` are null. `usedByApp()` (your app's own open bills) is always there. The user sees their own limit in `$user->billingOverview()->billing_limit` (with `used`, without `used_by_app`; null while they can't set one). Apps that may charge can read the limit; others need Anikeen to allow it (403 `billing_limit_not_enabled`). Handling a refused charge: ```php use Anikeen\Id\Exceptions\ApiException; try { $charge = $anikeen->createChargeForUser($user->anikeen_id, $attributes); } catch (ApiException $e) { if ($e->isBillingLimitReached()) { $e->body()->remaining; // room left before this charge, e.g. "2.60" $e->body()->required; // the refused charge, gross $e->body()->resets_at; // when the limit has room again at the latest return $shipment->holdForBillingLimit(); } match ($e->error) { 'charge_exceeds_maximum' => report($e), // above your app's maximum ($e->body()->max_charge_net) 'group_not_allowed' => report($e), 'billing_address_missing' => $shipment->askForBillingAddress(), default => throw $e, }; } ``` **Webhooks.** `billing_limit.reached` and `billing_limit.available` are sent when the limit is reached or has room again (a raised or removed limit, a void, the monthly reset). They are **opt-in** (select them for your endpoint; `*` doesn't include them) and go only to apps that may read the limit of a user connected to them. `data.object` is the billing limit as your app reads it, `data.reason` says why (`booking`, `limit_changed`, `limit_removed`, `voided`, `period_reset`, `order_changed`). Keep the highest `version` per user and ignore events with a lower or equal one, so a late delivery can't undo a newer state. In the `match` of the [webhook route](#webhooks): ```php EventType::BILLING_LIMIT_REACHED, EventType::BILLING_LIMIT_AVAILABLE => Customer::syncBillingLimit( $object['user_id'], reached: $event['type'] === EventType::BILLING_LIMIT_REACHED, version: $object['version'], ), ``` The limit is checked live when a charge is booked, so a late or missed webhook never lets a charge through; poll `billingLimitForUser()` if you need the current state. #### Error codes | Status | `error` | |--------|---------| | 403 | `client_not_allowed` (not a trusted app with its own token), `charges_not_enabled` (charging is not enabled for your app; also for reads and voids), `billing_limit_not_enabled` (limit read only) | | 404 | `user_not_found` (unknown, deleted or not connected to your app), `charge_not_found` | | 409 | `billing_limit_reached` (`isBillingLimitReached()`), `charge_exceeds_maximum` (+ `max_charge_net`), `group_not_allowed`, `idempotency_key_conflict` (+ `charge_id`), `billing_address_missing`, `charge_already_billed` (+ `charge_id`) | | 422 | validation (`errors`), no `error` code | | 429 | `too_many_requests` (`isTemporary()`) | ### Checkout sessions (the user confirms several subscriptions at once) Instead of checking subscriptions out itself, an app can let the user confirm them on Anikeen ID, all of them in one step: a checkout session bundles new subscriptions (or existing drafts of the app, `['id' => …]`) of one user. The user sees every item with its price and the total of the first period, confirms (all subscriptions start, or none) or cancels, and returns to `return_url`. Open sessions expire after 24 hours with their drafts. Trusted apps can still check out directly; sessions are opt-in. Example: a cart with several products and coupon prices: ```php use Anikeen\Id\Exceptions\ApiException; $session = $request->user()->appCheckoutSessions()->create([ // app token; or ->checkoutSessions() with the user's token 'return_url' => route('builder.done'), // same origin as one of your redirect URIs 'metadata' => ['cart' => $cart->id], // returned as is 'idempotency_key' => "cart:{$cart->id}", // retries return the same session 'subscriptions' => $cart->items->map(fn ($item) => [ 'group' => $team->id, 'name' => $item->product->name, 'description' => $item->label, 'unit' => $item->unit, // hour, day, week, month, year 'price' => $item->priceAfterCoupon(), // net, per unit 'payload' => ['cart_item' => $item->id], ])->all(), ]); $cart->update(['checkout_session_id' => $session->id, 'subscription_ids' => $session->subscriptionIds()]); return redirect($session->confirmationUrl()); // later: https://console.test/builder/done?status=completed&checkout_session=01K… ``` Provision when the webhook arrives (or after checking the session through the API; never trust the query string): ```php use Anikeen\Id\Webhooks\EventType; match ($event['type']) { EventType::CHECKOUT_COMPLETED => Provisioner::provision($event['data']['object']['subscriptions']), // all running EventType::CHECKOUT_CANCELLED, EventType::CHECKOUT_EXPIRED => Cart::release($event['data']['object']['metadata']['cart'] ?? null), default => null, }; app(AnikeenId::class)->appCheckoutSessions()->find($id)?->isCompleted(); // any user's session $session->refresh()->subscriptions(); // AppSubscription objects $session->cancel(); // withdraw an open session and its drafts (e.g. the cart changed) ``` While a session is open, its subscriptions can't be checked out or repriced by the app (409 `in_checkout_session` / `not_changeable`). The session's `status` is `open`, `completed`, `cancelled` or `expired` (`isOpen()`, `isCompleted()`, `isCancelled()` for cancelled or expired). `subscription.activated` / `subscription.cancelled` are still sent per subscription, before the `checkout.*` event of the session. ### User accounts (trusted apps, no user token) Trusted apps can look up and create Anikeen ID accounts with the app's own token. Email addresses are matched regardless of case. ```php $anikeenId = app(AnikeenId::class); $account = $anikeenId->findUserByEmail('erika@example.com'); // null if no account uses it $account?->data->id; // also name, email, email_verified, has_billing_address $result = $anikeenId->createUser([ // finds the account of the email address or creates it 'name' => 'Erika Mustermann', 'email' => 'erika@example.com', 'send_invitation' => true, // a new account gets a mail with a link to set a password ]); $result->data->created; // false: the account already existed (and is unchanged) $result->data->invitation_sent; // only for an account created by this call without a password ``` `findUserByEmail()` changes nothing. It throws `ApiException` when the request fails for any reason other than an unknown address. ## User profiles ### Changing the profile (user token) With a token of the user carrying `Scope::USER` (`user`), your app can change the user's name, username, email address and profile picture. Each call returns the updated profile (`Anikeen\Id\Resources\User`) and clears the cached `getUserData()`. ```php use Anikeen\Id\Exceptions\ValidationFailedException; try { $profile = $user->updateProfile([ // only the fields sent change 'name' => 'Erika Mustermann', 'username' => 'erika', 'email' => 'erika.new@example.com', ]); } catch (ValidationFailedException $e) { return back()->withErrors(match ($e->errorCodeFor('username')) { 'username_taken' => ['username' => 'This username is taken.'], default => $e->errors(), }); } $profile->email(); // still the current address $profile->pendingEmail(); // "erika.new@example.com" until the user confirms it $profile->pendingEmailExpiresAt(); $user->uploadAvatar($request->file('avatar')); // a path or a file $user->uploadAvatar($path, ['x' => 120, 'y' => 40, 'size' => 512]); // a square, in pixels $user->removeAvatar(); // back to the initials ``` - **Email addresses** never change right away. Anikeen ID mails a confirmation link to the new address and a notice to the current one; the new address becomes the user's address only once they confirm it, signed in to Anikeen ID. Until then the current one stays valid for sign-in and mails, and `user.updated` follows the confirmation. A new request replaces a pending one; a pending address is dropped after 7 days (`pendingEmailExpiresAt()`). - **Pictures:** jpeg, png, webp or gif, at most 5 MB and 64 to 8000 pixels per side; without `crop` the centre is used. The `avatar` URL stays the same when the picture changes. - **Limits:** 30 requests a minute per app and user, new email addresses 5 an hour and picture uploads 10 an hour per user (429 `too_many_requests` with `Retry-After`). Requests with an address that is taken count too. | Status | `error` | |--------|---------| | 401 | `unauthenticated` | | 403 | `missing_scope` (no `user` scope), `user_token_required` (an app token: there is no user to change) | | 413 | `avatar_too_large` (the upload is larger than the server accepts) | | 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid` (keyed by `crop.x`, `crop.y` or `crop.size`; `errorCodeFor('crop')` finds it), else `validation_failed` | | 429 | `too_many_requests` | Branch on the codes, the messages are for people and may change. ### Reading a user by ID (trusted apps) [Trusted apps](#trusted-apps) can read the profile of a user connected to them (a token of the user, a subscription or an order of the app) with the app's own token, e.g. to catch up on a missed `user.updated`: ```php $profile = app(AnikeenId::class)->findUserById($user->anikeen_id); // null: unknown, deleted or not connected $profile?->name(); $profile?->email(); // returned even while unverified $profile?->emailVerified(); $profile?->avatar(); $profile?->version(); // compare with the version of user.updated $profile?->locale; // also username, created_at ``` It throws `ApiException` for 403 `client_not_allowed` (not a trusted app), 429 `too_many_requests` and other failures. The profile carries no billing or account data; read those through their own endpoints. ## 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 `SharedPreference::APPEARANCE` with `Appearance::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. Use another namespace on staging (e.g. `acme-staging`), so test data never mixes with production. **Registration.** Access never comes from scopes alone: any client can request any scope. Ask Anikeen to register your OAuth client(s) (the app has to be [trusted](#trusted-apps)) 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 below). Without it, app requests fail with 403 `client_not_trusted` or 404 `namespace_not_found`. **Scopes.** User tokens need `Scope::PREFERENCES` (`preferences`, read and write) or `Scope::PREFERENCES_READ` (`preferences:read`); app tokens `Scope::PREFERENCES_CLIENT` (`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 user's shared preferences (user token) ```php use Anikeen\Id\Enums\Appearance; use Anikeen\Id\Enums\SharedPreference; $user->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM); // the default when not set $user->globalPreferences()->values(); // ['appearance' => 'dark'] $preference = $user->setGlobalPreference(SharedPreference::APPEARANCE, Appearance::DARK); $preference->version; // e.g. 1042 $user->deleteGlobalPreference(SharedPreference::APPEARANCE); ``` User tokens reach `global` only; an app namespace answers 404 `namespace_not_found`, whatever the token's scopes. The app's own token (client credentials) has no user and gets 401 on these routes. ### Preferences of any user (trusted apps) With the app's own token, by the user's Anikeen ID: ```php $anikeenId = app(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 {} $preference->version; $preference->updated_by; // {type: "user"|"app", client_id} $anikeenId->setPreferenceByUserId($userId, 'acme', 'onboarding', ['step' => 3]); $anikeenId->deletePreferenceByUserId($userId, 'acme', 'onboarding'); ``` A value is any JSON value; pass `(object) []` for an empty object (`[]` is an empty list). `setPreferenceByUserId()` returns the stored preference; `wasCreated()` tells whether the write created the key. A deleted user's preferences are gone: every call answers 404 `user_not_found`. ### Conditional writes and retries 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. Pass the version you read to write only if nobody changed it in between (`If-Match`), or `createOnly` to write only if the key isn't set yet (`If-None-Match: *`): ```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->error; // "preference_version_mismatch" (or "preference_exists" for createOnly) $e->currentVersion(); // the version stored now, null when the key isn't set: reload, merge, retry } $anikeenId->setPreferenceByUserId($userId, 'acme', 'welcome_sent_at', now()->toIso8601String(), createOnly: true); ``` `deletePreferenceByUserId()`, `setGlobalPreference()` and `deleteGlobalPreference()` take `ifVersion` too. Writing the value that is already stored is a no-op that succeeds (no new version), even when the precondition would fail, so retrying a write after a timeout is safe. ### 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`. Your app can then find the user by value, e.g. in a payment provider's webhook: ```php $userId = $anikeenId->findUserIdByPreference('acme', 'customer_id', 'cus_123'); // null if nobody holds it ``` The value is sent in the body, never in the URL. `findUserIdByPreference()` returns `null` only for 404 `preference_not_found` (also when the user was deleted); everything else throws, e.g. 422 `key_not_unique` for a key that isn't declared unique. ### Error codes Failures throw `ApiException` (412 `PreconditionFailedException`) with these `error` codes: | 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` | A `null` value is rejected with 422 `value_required`: delete the key instead. ### Caching Anikeen ID is the source of truth. `globalPreferences()` and `globalPreference()` cache the list on the model instance (one request per instance); `setGlobalPreference()` and `deleteGlobalPreference()` clear it. 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 select the [`preference.updated`](#user-and-preference-events) webhook to drop it sooner): ```php $appearance = Cache::remember("appearance:{$user->id}", now()->addHour(), fn () => $user->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM)); $user->setGlobalPreference(SharedPreference::APPEARANCE, $request->appearance); Cache::put("appearance:{$user->id}", $request->appearance, now()->addHour()); ``` ## API authentication Use this when your own API accepts access tokens issued by Anikeen ID. ### Registering Middleware To call your API from your own frontend with the user's session, append the middleware to the web middleware group in your application's `bootstrap/app.php` file: ```php ->withMiddleware(function (Middleware $middleware) { $middleware->web(append: [ \Anikeen\Id\Http\Middleware\CreateFreshApiToken::class, ]); }) ``` ### Implementing Auth (API tokens) The package registers an `anikeen` guard driver and an `anikeen` user provider. Reference them in `config/auth.php`: ```php 'guards' => [ 'web' => [ 'driver' => 'session', 'provider' => 'users', ], 'api' => [ 'driver' => 'anikeen', 'provider' => 'anikeen', ], ], 'providers' => [ 'users' => [ 'driver' => 'eloquent', 'model' => App\Models\User::class, ], 'anikeen' => [ 'driver' => 'anikeen', 'model' => App\Models\User::class, 'column' => 'anikeen_id', // optional, see below ], ], ``` The guard accepts `Authorization: Bearer ` with an access token issued by Anikeen ID (personal access token or OAuth token, a JWT). It verifies the RS256 signature with Anikeen ID's public key and the time claims (`exp`, `nbf`, `iat`, 60 seconds leeway). The token's `sub` (the user's Anikeen ID) is looked up in the column `anikeen_id` of the user model: the provider's `column`, else `services.anikeen.user_id_column`, else `AnikeenId::useUserIdField()`. Tokens of unknown users, client credentials tokens (no `sub`), expired or forged tokens leave the request unauthenticated, so `auth:api` answers `401 {"message": "Unauthenticated."}` to JSON requests. The user model uses `Anikeen\Id\HasAnikeenTokens`; the token's scopes are then available: ```php $request->user()->anikeenTokenCan('user'); // has the scope (or `*`) $request->user()->anikeenTokenCanAny(['user', 'user:read']); ``` or as middleware (`403` without the scope): ```php use Anikeen\Id\Http\Middleware\CheckForAnyScope; use Anikeen\Id\Http\Middleware\CheckScopes; Route::middleware(['auth:api', CheckScopes::using('user')])->post(...); // all scopes Route::middleware(['auth:api', CheckForAnyScope::using('user', 'user:read')])->get(...); // any scope ``` Options in `config/services.php` (`anikeen`): | Key | Default | | |-----|---------|-| | `public_key` | – | Anikeen ID's public key (PEM; `\n` escapes allowed) | | `public_key_path` | – | path to that key; without both the key shipped for the `mode` is used | | `user_id_column` | `anikeen_id` | column holding the user's Anikeen ID | | `verify_revocation` | `false` | also ask Anikeen ID (`GET v1/user`) whether the token is still valid, so revoked tokens stop working before they expire; fails closed when Anikeen ID is unreachable | | `revocation_cache_ttl` | `300` | seconds that answer is cached per token | The key is read once per process. In tests `AnikeenId::actingAs($user, ['user'])` signs in without a token. ## General #### Setters and Getters ```php $anikeenId = new Anikeen\Id\AnikeenId(); $anikeenId->setClientId('abc123'); $anikeenId->setClientSecret('abc456'); $anikeenId->setToken('abcdef123456'); $anikeenId = $anikeenId->withClientId('abc123'); $anikeenId = $anikeenId->withClientSecret('abc123'); $anikeenId = $anikeenId->withToken('abcdef123456'); ``` #### Results and resources Low-level methods on `AnikeenId` (e.g. `getAuthedUser()`, `createOrderPreview()`, `refreshToken()`) return a `Result`. Check it before you use it: ```php $result = $anikeenId->getAuthedUser(); if (!$result->success()) { die('Ooops: ' . $result->error()); } $user = $result->shift(); // the object, or the first item of a list ``` Resource methods (`$user->orders()`, `$anikeenId->sshKeysByUserId()`, …) return objects and throw `ApiException` instead (see [Errors](#errors)). ## Examples #### Get User SSH Keys Uses the app's own token (client credentials): ```php $sshKeys = app(Anikeen\Id\AnikeenId::class)->sshKeysByUserId($anikeenUserId); foreach ($sshKeys->toArray() as $sshKey) { echo $sshKey->name; } ``` #### OAuth Tokens ```php $anikeenId = new Anikeen\Id\AnikeenId(); $anikeenId->setToken('abcdef123456'); $user = $anikeenId->getAuthedUser()->shift(); ``` ```php $user = $anikeenId->withToken('uvwxyz456789')->getAuthedUser()->shift(); ``` #### Facade ```php use Anikeen\Id\Facades\AnikeenId; AnikeenId::withToken('abcdef123456')->getAuthedUser(); ``` ## Documentation ## AnikeenId ### Oauth ```php public function retrievingToken(string $grantType, array $attributes): Result ``` ### ManagesPreferences ```php public function preferencesByUserId(string $userId, string $namespace): Preferences public function preferenceByUserId(string $userId, string $namespace, string $key): Preference public function setPreferenceByUserId(string $userId, string $namespace, string $key, mixed $value, ?int $ifVersion = null, bool $createOnly = false): Preference public function deletePreferenceByUserId(string $userId, string $namespace, string $key, ?int $ifVersion = null): void public function findUserIdByPreference(string $namespace, string $key, string $value): ?string ``` ### ManagesPricing ```php public function createOrderPreview(array $attributes = []): Result ``` ### ManagesProducts ```php public function products(): Result public function boxes(): Result ``` ### ManagesShippingLabels ```php public function shippingLabels(array $parameters = []): Result public function shippingLabel(string $id): Result public function shippingLabelPdf(string $id): ?string public function issueShippingLabel(array $attributes): Result public function shipShippingLabel(array $hex): Result public function shippingPriceList(): Result ``` ### ManagesUserBilling ```php public function appOrders(): AppOrders public function appCheckoutSessions(): AppCheckoutSessions public function createBillingPortalLinkForUser(string $userId, array $attributes = []): object public function createOrderForUser(string $userId, array $attributes): Result public function createTransactionForUser(string $userId, array $attributes): Result public function createChargeForUser(string $userId, array $attributes): AppCharge public function findChargeForUser(string $userId, string $chargeId): AppCharge public function voidChargeForUser(string $userId, string $chargeId, ?string $reason = null): AppCharge public function billingLimitForUser(string $userId): BillingLimit ``` ### ManagesSocialiteUsers ```php public function socialiteUsers(): SocialiteUsers ``` ### ManagesSshKeys ```php public function sshKeysByUserId(string $userId): SshKeys ``` ### ManagesUsers ```php public function getAuthedUser(): Result public function createUser(array $attributes): Result public function findUserByEmail(string $email): ?Result public function findUserById(string $userId): ?User public function findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result public function refreshToken(string $storedRefreshToken, string $scope = ''): Result public function isEmailExisting(string $email): Result public function setParent(mixed $parent): self public function getParent() ``` ## Billable ### ManagesAddresses ```php public function addresses(): Addresses public function hasDefaultBillingAddress(): bool public function setBillable(Billable|Model $billable): self public function getBillable(): Billable ``` ### ManagesBalance ```php public function balance(): float public function charges(): float public function billingOverview(): object public function charge(string|float $amount, string $paymentMethodId, array $options = []): Transaction public function setBillable(Billable|Model $billable): self public function getBillable(): Billable ``` ### ManagesCountries ```php public function countries(): Countries public function setBillable(Billable|Model $billable): self public function getBillable(): Billable ``` ### ManagesInvoices ```php public function invoices(array $parameters = []): Invoices public function setBillable(Billable|Model $billable): self public function getBillable(): Billable ``` ### ManagesOrders ```php public function orders(array $parameters = []): Orders public function setBillable(Billable|Model $billable): self public function getBillable(): Billable ``` ### ManagesPaymentMethods ```php public function paymentMethods(): PaymentMethods public function hasPaymentMethod(): ?PaymentMethod public function defaultPaymentMethod(): ?PaymentMethod public function hasDefaultPaymentMethod(): bool public function setDefaultPaymentMethod(string $id): PaymentMethod public function billingPortalUrl(?string $returnUrl = null, array $options = []): string public function createSetupIntent(array $options = []): Result public function setBillable(Billable|Model $billable): self public function getBillable(): Billable ``` ### ManagesProfile ```php public function profilePortalUrl(?string $returnUrl = null, array $options = []): string public function updateProfile(array $attributes): User public function uploadAvatar(SplFileInfo|string $file, ?array $crop = null): User public function removeAvatar(): User public function setBillable(Billable|Model $billable): self public function getBillable(): Billable ``` ### ManagesSubscriptions ```php public function subscriptions(): Subscriptions public function appSubscriptions(): AppSubscriptions public function appCheckoutSessions(): AppCheckoutSessions public function checkoutSessions(): CheckoutSessions public function appOrders(): AppOrders public function appBillingPortalUrl(array $attributes = []): string public function setBillable(Billable|Model $billable): self public function getBillable(): Billable ``` ### ManagesTaxation ```php public function vatRate(): float public function setBillable(Billable|Model $billable): self public function getBillable(): Billable ``` ### ManagesTransactions ```php public function transactions(): Transactions public function setBillable(Billable|Model $billable): self public function getBillable(): Billable ``` ### ManagesUserPreferences ```php public function globalPreferences(): Preferences public function globalPreference(string $key, mixed $default = null): mixed public function setGlobalPreference(string $key, mixed $value, ?int $ifVersion = null): Preference public function deleteGlobalPreference(string $key, ?int $ifVersion = null): void ``` ### ManagesUserSshKeys ```php public function sshKeys(): SshKeys public function setBillable(Billable|Model $billable): self public function getBillable(): Billable ``` [**OAuth Scopes Enums**](https://github.com/anikeen-com/id/blob/main/src/Id/Enums/Scope.php) ## Development #### Run Tests ```shell composer test ``` #### Generate Documentation ```shell composer docs ```