App subscriptions (trusted apps)
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 – e.g. to bill servers by the hour from a backend job.
Your app sees the subscriptions it created; others answer 404.
Endpoints
All under /api/v1/client/subscriptions:
| Method | Path | |
|---|---|---|
GET | / | list; filters user_id, group (exact), active, cancelled, per_page |
POST | / | create, see below |
GET | /{id} | show |
PUT | /{id} | change a subscription that wasn't checked out yet (afterwards only the description) |
PUT | /{id}/checkout | start billing: starts_at?, bill_elapsed_periods?, max_net_amount?, withdrawal_waiver? |
PUT | /{id}/change | change a running subscription, see below |
DELETE | /{id}/change | drop the change scheduled for the period end |
PUT | /{id}/pause | pause |
PUT | /{id}/resume | resume |
PUT | /{id}/revoke | cancel; refund credits usage not yet charged |
POST | /monthly-caps | set or lower the monthly cap of running subscriptions |
GET | /{id}/usage | metered usage records |
POST | /{id}/usage | report metered usage |
DELETE | /{id}/usage/{record} | void a usage record |
Create fields: user_id, group, name, description, unit, price, monthly_cap?, predecessor_id?, takeover?, vat_rate?, payload?, ends_at?, trial fields, discount? (see Trials & discounts) and idempotency_key? – a repeated key returns the first subscription (200).
With the SDK
$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->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).
Starting from an earlier date
checkout with starts_at 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 409 period_already_ended, unless you pass bill_elapsed_periods: true with max_net_amount (net, up to two decimals; both together):
- 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 monthly bill 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 (
422otherwise). - if the periods come to more than
max_net_amount, nothing is booked:409 elapsed_amount_exceededwith the real amount innet_amount(e.g."20.76"), so you can show it and retry. 409 elapsed_periods_not_allowedwhen the subscription can't be billed this way (e.g. it ran before or replaces another one).- Hourly and daily subscriptions with a monthly cap are capped per calendar month for the elapsed months;
max_net_amountlimits the total.
use Anikeen\Id\Exceptions\ApiException;
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;
}
}Changing running subscriptions
PUT …/{id} changes a subscription before its checkout. PUT …/{id}/change changes price, unit, name, description or discount of a running subscription, like a plan change (send only what changes), with apply:
use Anikeen\Id\Resources\AppSubscription;
// upgrade now, prorated
$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 allnowcredits 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 newunitstarts a new, fully billed period now. A downgrade whose credit would exceed the open bill is refused with409 credit_exceeds_bill: schedule it for the period end instead.409 proration_not_possiblewhen the current period wasn't billed to the subscription;period_endworks.period_endchanges nothing now; the subscription hasscheduled_changeand the next period is billed at the new values. A newperiod_endchange replaces the scheduled one,nowdrops it.- During a free trial nothing was billed: both just change what the trial ends in.
discountreplaces the discount from the next billed period on,nullends it. When the unit changes, a discount that counts in periods or anamount_offhas to be sent again (422).- Subscriptions with a monthly cap change only with
period_end. - Only running subscriptions can change:
409 not_changeablefor drafts (usePUT …/{id}), paused, cancelling, expired and cancelled ones.
When a change takes effect, Anikeen ID sends subscription.updated with the old values in data.previous_attributes. Telling the user about a price increase (and getting consent where needed) is up to your app.
Monthly caps
Hourly and daily subscriptions can have a monthly_cap (net): billed per unit, but per calendar month at most the cap. A resize – a new subscription with predecessor_id naming the one it replaces – shares the cap with its predecessor within the month.
POST …/monthly-caps gives running subscriptions a cap or a lower one – only in the customer's favour (never a higher cap, never none). It applies to usage booked from then on.
{"subscriptions": [{"id": "01K…", "monthly_cap": "5.00"}], "notify": true}| Field | |
|---|---|
subscriptions | 1–100 items of {id, monthly_cap} |
notify | true: each owner gets one mail listing their changed subscriptions |
reason | optional |
Each subscription answers with status updated, unchanged or refused (with error: subscription_not_found, not_running, invalid_monthly_cap, monthly_cap_not_lowered), previous_monthly_cap, monthly_cap and notified.
Transfers
When a product changes hands in your app (e.g. between teams), the recipient can take over billing with a subscription of their own: create it with takeover: true and predecessor_id = the previous holder's running subscription (same app, another user, same unit). The recipient confirms it like a purchase (e.g. in a checkout session). On checkout the predecessor is revoked without refund and the new subscription continues from the end of the predecessor's paid period: no period is billed twice, none is left out.
Metered usage
Usage your app measures itself (traffic above what is included, storage) is reported per subscription, meter and period once the period has ended. It becomes a line of its own on the user's open monthly bill (usage of October reported on November 1st is on November's bill; the line states October as its period). It is not subject to the monthly cap.
POST /api/v1/client/subscriptions/{id}/usage
{
"meter": "server_traffic",
"period_start": "2026-10-01T00:00:00Z",
"period_end": "2026-11-01T00:00:00Z",
"quantity": "12.5",
"unit": "gigabyte",
"unit_price": "0.01",
"idempotency_key": "traffic:server-42:2026-10"
}| Field | |
|---|---|
meter | [a-z0-9_.-], e.g. server_traffic |
period_start, period_end | the period; it must have ended |
quantity | > 0, up to 4 decimals |
unit | gigabyte (10⁹ bytes) or gibibyte (2³⁰ bytes) |
unit_price | net, up to 6 decimals |
description | optional, default: the subscription's |
idempotency_key | required; a retry returns the first record (200) |
The response is a usage_record: {object, id, subscription_id, meter, period_start, period_end, quantity, unit, unit_price, amount, description, idempotency_key, status, order_id, order_item_id, voided_at, created_at} with status open, charged or voided.
Another record for the same or an overlapping period of the meter answers usage_already_recorded (with its usage_record_id). DELETE …/usage/{record} voids a record while its bill is open; afterwards 409 usage_already_charged. Revoking the subscription leaves reported usage charged.
Error codes
Failures are {message, error} with a stable error:
subscription_not_found (404), already_active, already_cancelled, not_active, already_paused, already_resumed, billing_address_missing, not_changeable, 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 (with net_amount), subscription_conflict (409).
try {
$subscription->revoke();
} catch (ApiException $e) {
if ($e->isNotFound() || $e->error === 'already_cancelled') {
// already gone
} elseif ($e->isTemporary()) {
// retry later
} else {
throw $e;
}
}SDK coverage
Monthly caps, transfers and metered usage have no dedicated SDK methods yet. Call them with the app token through asApp(), which returns a Result:
$result = app(\Anikeen\Id\AnikeenId::class)->asApp()
->post("v1/client/subscriptions/{$subscriptionId}/usage", $usage);