anikeen / console
PHP Anikeen Console API client for Laravel 12+
Requires
- php: ^8.2
- ext-json: *
- anikeen/id: ^3.6
- guzzlehttp/guzzle: ^7.8|^8.0
- illuminate/cache: ^12.69|^13.0
- illuminate/http: ^12.69|^13.0
- illuminate/routing: ^12.69|^13.0
- illuminate/support: ^12.69|^13.0
Requires (Dev)
- laravel/framework: ^12.69|^13.0
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
PHP client for the Anikeen Console API, for Laravel 12+ (PHP 8.2+).
Lets a trusted app act in the Anikeen Console for its users, with the user's own Anikeen ID
token: order products (the user confirms and pays on Anikeen ID), manage them, and learn about
changes through webhooks. Built on anikeen/id, which handles
the login, the tokens and the webhook signatures.
Table of contents
How it works
your user ── login (Anikeen ID, scope console-containers) ──▶ your app stores the token pair
your app ── AnikeenConsole::forUser($user)->appTeam() ──▶ the Console creates the user's team for your app
your app ── ->orders($team)->create([...]) ──▶ open order with checkout_url
your user ── confirms on Anikeen ID ──▶ webhook order.completed, later product.ready
- The Console accepts tokens of trusted apps only (otherwise
401 client_not_allowed). - Every app works in its own team of the user ("app team"), created on the first call. Other
teams and products of the user are invisible to your app (
404). The team and its products are read-only for the user in the Console UI; your app changes them. - The user does not need a Console account: it is created on the first call.
- Orders are never charged directly: the order is
openwith acheckout_url, the user confirms it on Anikeen ID.
Installation
composer require anikeen/console
The service provider and the AnikeenConsole facade are registered through package discovery.
Set up anikeen/id (login with Anikeen ID, storing the tokens on the user) first; see its README.
Configuration
ANIKEEN_CONSOLE_MODE=staging # optional; production by default
ANIKEEN_CONSOLE_BASE_URL= # optional, overrides the mode (e.g. a local Console)
ANIKEEN_CONSOLE_WEBHOOK_SECRET=whsec_… # the signing secret of your webhook endpoint
Add to config/services.php (so it still works with php artisan config:cache):
'anikeen_console' => [ 'mode' => env('ANIKEEN_CONSOLE_MODE'), 'base_url' => env('ANIKEEN_CONSOLE_BASE_URL'), 'webhook_secret' => env('ANIKEEN_CONSOLE_WEBHOOK_SECRET'), ],
| Mode | Base URL |
|---|---|
production (default) |
https://console.anikeen.com/api/v1 |
staging |
https://staging.console.anikeen.com/api/v1 |
Use ANIKEEN_ID_MODE=staging together with ANIKEEN_CONSOLE_MODE=staging: the staging Console
only accepts tokens of staging Anikeen ID.
The token refresh uses anikeen/id (services.anikeen.client_id / client_secret) and the token
fields it uses on your user model (anikeen_id_access_token, anikeen_id_refresh_token, or what
you set with AnikeenId::useAccessTokenField() / useRefreshTokenField()).
Scopes
Request the Console scopes in your Anikeen ID login (Anikeen\Id\Enums\Scope::CONSOLE_*). One
pair per product family: the full scope reads, changes and orders, :read only reads. The catalog
lists the families your token covers; an order needs the scope of every item's family.
| Area | Scope | Read only |
|---|---|---|
Containers (containers(), orders of family container) |
console-containers |
console-containers:read |
| Servers and floating IPs (not in the SDK yet) | console-servers |
console-servers:read |
| Domains (not in the SDK yet) | console-domains |
console-domains:read |
| DNS zones (not in the SDK yet) | console-zones |
console-zones:read |
| Web hosting (not in the SDK yet) | console-hosting |
console-hosting:read |
| Load balancers (not in the SDK yet) | console-load-balancers |
console-load-balancers:read |
| Object storage (ordering only) | console-object-storage |
console-object-storage:read |
appTeam(), catalog(), orders()->list/find |
any console-* scope |
A missing scope answers 403 missing_scope (MissingScopeException::scope() names it). The
scope user does not open the Console API for apps.
return Socialite::driver('anikeen') ->scopes([Scope::CONSOLE_CONTAINERS]) ->redirect();
Usage
use Anikeen\Console\Facades\AnikeenConsole; $console = AnikeenConsole::forUser($user); // the user's stored token, refreshed when needed $team = $console->appTeam(); // GET v1/app-team: {id, name}, created on the first call // what can be ordered, with the options for the order attributes $catalog = $console->catalog($team); $plans = $catalog->products('container'); $locations = $catalog->options('container')->locations; // only GPU plans, NVIDIA, at most 150 € net per month, cheapest first $gpuPlans = $console->catalog($team, [ 'gpu' => true, 'gpu_vendor' => 'nvidia', // nvidia | amd | intel 'max_price' => 150, // on monthly_price: net, the team's price, hourly plans at their monthly cap 'sort' => 'price', // price | -price ])->products('container'); $order = $console->orders($team)->create([ ['family' => 'container', 'product_id' => $planId, 'attributes' => [ 'location_id' => 'bon1', 'container_template_id' => $templateId, 'vlan_id' => $vlanId, 'ssh_key' => $sshKeyId, // optional 'name' => 'game-1', // optional hostname ]], ], ['tos_accepted' => true]); // required: only once your user accepted the terms return redirect($order->checkout_url); // the user confirms (and pays) on Anikeen ID
Containers that run their own containers inside (e.g. rootless Podman) need LXC options. They are
allow-listed: features fuse (FUSE file systems such as gocryptfs) and keyctl (kernel keyrings),
devices tun (/dev/net/tun); nesting is always on, anything else answers 422. Gleem, for
example, orders a GPU plan with all three:
use Anikeen\Console\Resources\Container; $console->orders($team)->create([ ['family' => 'container', 'product_id' => $gpuPlanId, 'attributes' => [ 'location_id' => 'bon1', 'container_template_id' => $templateId, 'vlan_id' => $vlanId, 'options' => [ 'features' => [Container::FEATURE_FUSE, Container::FEATURE_KEYCTL], 'devices' => [Container::DEVICE_TUN], ], ]], ], ['tos_accepted' => true]); $container->options; // {features: ["fuse", "keyctl"], devices: ["tun"]} $container->hasDevice(Container::DEVICE_TUN); // true $container->reinstall($templateId); // keeps the options; pass new ones as third argument
The catalog lists the allowed values in $catalog->options('container')->options.
Send the user back to your app after the checkout with return_url (app tokens only; https, plain
http only for localhost). The Console redirects there with the order id and its status appended:
$order = $console->orders($team)->create($items, [ 'tos_accepted' => true, 'return_url' => route('ordered'), // → https://your.app/ordered?console_order=01JE…&status=completed ]); // routes/web.php Route::get('/ordered', function (Request $request) use ($console, $team) { // never trust the query: read the order (completed once Anikeen ID confirmed it) $order = $console->orders($team)->find($request->query('console_order')); return view('ordered', ['order' => $order]); })->name('ordered');
Catalog filters (all optional, applied by the Console; families left without products are left
out): family (alias or list), gpu (true/false), gpu_vendor, gpu_model (substring),
gpu_mode (shared/exclusive), gpu_min_vram_gb, gpu_min_count, min_vcores,
min_memory_mb, min_disk_gb, min_price / max_price (each product's monthly_price: what a
month costs at most, net, at the team's price) and sort (price/-price). A bad value throws
ValidationFailedException.
Tokens
forUser($user)reads the tokensanikeen/idstores on the user. A token that expires within a minute is refreshed throughAnikeenId::refreshToken()before the request; a401refreshes it and sends the request once more. The new pair is saved on the user (only those two columns). Refreshes are locked per user through the cache, and a pair another process stored meanwhile is used instead of refreshing again (refresh tokens are single-use).- When the refresh fails, an
AuthenticationExceptionasks you to send the user through the Anikeen ID login again. A user without any token throwsanikeen/id'sRequestRequiresAuthenticationException. AnikeenConsole::withToken($token)uses a fixed token (e.g. a personal access token in a script) and never refreshes it.- The shared
AnikeenConsoleinstance keeps no token:forUser()andwithToken()return copies.
Orders
$orders = $console->orders($team); // a Team or its id $order = $orders->create($items, ['tos_accepted' => true, 'withdrawal_waiver' => true, 'coupon_code' => 'SPRING']); $order->idempotencyKey; // the Idempotency-Key that was sent (a new UUID per create) // your own key, e.g. the id of your own order: a retry after a crash returns the first order $order = $orders->withIdempotencyKey("gleem-order-{$localOrder->id}")->create($items, ['tos_accepted' => true]); $order->isOpen(); // status: pending, open, completed, cancelled, expired, failed $order = $order->refresh(); // GET …/orders/{order}; asks Anikeen ID about an open order $order->products(); // [{type: "container", id: "01JD…"}, …] once provisioned foreach ($orders->list(['status' => 'open']) as $order) { … } $order = $orders->find($orderId);
The retry after a token refresh sends the same Idempotency-Key, so an order is never placed twice.
Keys live 24 hours per team; the same key with another body is refused
(ValidationFailedException, error idempotency_key_reused). At most 10 orders per minute and
team (RateLimitException).
Containers
$containers = $console->containers(); foreach ($containers->list() as $container) { … } // without status $container = $containers->find($id); // with status: running, stopped (null: unknown) $container->power('reboot'); // start, stop, shutdown, reboot → 202 $container->reinstall($templateId, $sshKeyId); // all data and snapshots are lost $container->update(['name' => 'game-2']); // hostname, inside after the next restart $container->delete(); // confirmed with its name; kept 14 days $container->backups()->list(); // or $containers->backups($id) $container->backups()->create('before update'); $container->backups()->restore($backupId); $container->backups()->delete($backupId); $container->snapshots()->list(); $container->snapshots()->create('before_update', 'Before the update'); $container->snapshots()->rollback('before_update'); $container->snapshots()->delete('before_update');
Operations answering 202 (power, reinstall, backups, snapshots) run in the background; read the
container again, or wait for the product.updated webhook.
Lists
Lists return a BaseCollection of resources: iterate it, count(), first(), all(),
total(), currentPage(), lastPage(), hasMorePages(), nextPage(), previousPage();
paginate() gives the raw Result.
Resources
Resources expose the API's fields as properties (nested objects as stdClass, unknown fields read
as null), toArray() and JSON serialization. $resource->result holds the response.
Errors
Failed requests throw a subclass of Anikeen\Console\Exceptions\ApiException (which extends
anikeen/id's ApiException): status, error (the Console's code), getMessage(), body(),
isTemporary() (unreachable, 5xx or 429).
| Exception | When |
|---|---|
AuthenticationException |
401, also after a failed refresh: sign the user in again |
ClientNotAllowedException |
401 client_not_allowed: the Console does not accept your app's tokens (not trusted) |
MissingScopeException |
403 missing_scope; scope() names the missing scope |
NotFoundException |
404: no such object, or not in your app team |
AccountConflictException |
409 account_conflict: another Console account uses the user's e-mail address |
ValidationFailedException |
422; errors() per field (e.g. items.0.attributes.location_id), errorFor($field) |
RateLimitException |
429; retryAfter in seconds |
ApiException |
everything else, e.g. 403 email_not_verified, 403 owner_only, 409 (deletion protection, suspended), 502 checkout_unavailable; status 0 when the Console was not reachable |
Webhooks
The Console tells your app when an order went through and a product is ready. Webhook endpoints
are set up by Anikeen on request (URL, event types); you get the signing secret whsec_….
Register the route (no CSRF protection needed, the macro excludes it):
// routes/web.php or routes/api.php Route::anikeenConsoleWebhooks('webhooks/anikeen-console');
It verifies the Anikeen-Signature with services.anikeen_console.webhook_secret (anikeen/id's
Webhook::constructEvent(), the same format as Anikeen ID's webhooks; 400 otherwise), ignores
events it has already handled (by event id, remembered 3 days in the cache) and dispatches
Anikeen\Console\Events\WebhookReceived for every event plus a typed event:
Type (Webhooks\EventType) |
Event | $event->object |
|---|---|---|
order.completed |
OrderCompleted |
Order: checkout confirmed, provisioning started |
order.failed |
OrderFailed |
Order: checkout cancelled, expired or could not be opened |
product.ready |
ProductReady |
Container (or Product): built; status is the live status (container: running) when the Console could read it, else active |
product.failed |
ProductFailed |
Product: building one order item failed (refunded); id is null, with order_id, order_item_id, product_id, error |
product.updated |
ProductUpdated |
Product: data.reason is billing_suspended, billing_resumed, rescaled or reinstalled |
product.deleted |
ProductDeleted |
Product: deleted; data.reason contract_ended when the contract ended in Anikeen ID |
team.released |
TeamReleased |
Team: the user removed your app in Anikeen ID; the last event for that team |
webhook.test |
WebhookTest |
null (see $event->data) |
Every event has id, type, created, data (the whole data as an array) and object.
use Anikeen\Console\Events\ProductReady; Event::listen(function (ProductReady $event) { $server = GameServer::where('console_id', $event->object->id)->first(); $server?->markReady($event->object->status); });
Listeners may be queued. If a listener throws, the endpoint answers 500 and forgets the event
id, so the Console's retry gets through. Resources from webhooks have no API client; bind one to
act on them: $event->object->withClient(AnikeenConsole::forUser($user))->power('start').
Testing your app
use Anikeen\Console\Facades\AnikeenConsole; use Anikeen\Console\Testing\AnikeenConsoleFake; use Anikeen\Console\Testing\RecordedRequest; $fake = AnikeenConsole::fake([ // "METHOD path" or "path" (relative to the base URL, * matches anything) => response 'GET containers/01JCT' => ['id' => '01JCT', 'name' => 'game-1', 'status' => 'running'], 'POST containers/*/power' => fn (RecordedRequest $request) => ['message' => "Doing {$request->json['action']}"], 'GET catalog' => AnikeenConsoleFake::response(['message' => 'Missing scope.', 'error' => 'missing_scope'], 403), ]); $this->post('/servers', [...]); // your code calls AnikeenConsole::forUser($user)->... $fake->assertOrdered(fn (array $items, array $body, string $team) => $items[0]['family'] === 'container'); $fake->assertNothingOrdered(); $fake->assertSent('POST containers/*/power'); $fake->assertNotSent(fn (RecordedRequest $request) => $request->method === 'DELETE'); $fake->recorded(); // every RecordedRequest (method, path, query, json, header())
Arrays become JSON responses (201 for orders, else 200). Without a matching response the fake
answers with defaults: GET app-team a team (team_fake), POST teams/{team}/orders an open
order with a checkout_url, empty lists, 202 for other POSTs, 204 for deleting a container,
200 {} otherwise. Users need no tokens with the fake and nothing is refreshed.
Webhooks, signed with your configured secret and sent through your app:
use Anikeen\Console\Webhooks\EventType; AnikeenConsole::webhookRequest(EventType::PRODUCT_READY, [ 'id' => '01JCT', 'family' => 'container', 'team_id' => 'team_fake', 'status' => 'running', ])->send()->assertOk();
webhookRequest() returns a WebhookRequest with the event, the signed body and the headers;
send($uri) posts it to the webhook route, toRequest($uri) gives an Illuminate\Http\Request.
Development
composer install
composer test
See CLAUDE.md for the layout and conventions. A change to the Console API that apps use (new
endpoint, field or event) must be mirrored here; the Console's docs/API.md is the reference.