Skip to content

Lapse, Grace, and the Entitlement Check

A subscription's timing is one line: starts_at opens the window, expires_at closes it, and if the plan has a grace period, grace_ends_at closes that. Statuses do not change by themselves - a background lapse worker (every 60 seconds by default) sweeps due subscriptions and leases, moving active/trialing to grace when the plan grants one, and grace (or graceless expiry) to expired. The entitlement check only reads.

Two admin endpoints expose the machinery - lapses due previews due candidates, lapses run sweeps them immediately (both require a platform admin token). The transcript below is real: a grant backdated two days with a one-day duration, swept twice.

snug subscription grant docs-wave --plan-id njtGtYfYpECGYYbjBqmh \
  --starts-at 2026-08-26T12:49:43Z --duration-days 1
snug --output json subscription lapses due     # admin
snug --output json subscription lapses run     # admin
{ "subscriptions": [
    { "due_at": "2026-08-27T12:49:43Z", "subscription_id": "tCZUKPBCbzYyTdnNaPFL" } ],
  "page": { "has_more": false } }

lapses due is a list, most overdue first; it pages by cursor only and refuses total, since the expiry index is shared by every tenant.

{ "subscriptions_processed": 1,
  "subscriptions": [ { "id": "tCZUKPBCbzYyTdnNaPFL", "outcome": "grace" } ],
  "leases_processed": 0, "leases": [] }

A second sweep after the grace window elapses reports { "id": ..., "outcome": "expired" } - both transitions were observed live. Other outcomes are skipped (rescheduled or already moved) and missing (subscription deleted). Because the worker also runs on its own, a manual lapses run may find nothing to do.

Grace periods

Grace is plan policy: enabled, duration_days, and an access_level of full or limited - both keep allowed: true during grace, and the caller distinguishes them by reading status: "grace" from the check response and applying its own degraded experience. (access_level: none skips grace entirely: expiry goes straight to expired.) Verified during grace:

{ "allowed": true, "status": "grace", "plan_id": "njtGtYfYpECGYYbjBqmh",
  "expires_at": "2026-08-27T12:49:43Z", "subscription_id": "tCZUKPBCbzYyTdnNaPFL" }

The check is authoritative between sweeps

The check does not wait for the worker: it evaluates timestamps directly, so access ends the moment the window does, even if the status field still says active. Both gaps:

  • past expires_at, status still active - denied window_elapsed
  • past grace_ends_at, status still grace - denied grace_ended

Denial reasons

POST /api/v1/subscriptions/check walks the subscriber's subscriptions (owned plus seat memberships, up to a configured candidate cap) and returns the first that allows. On denial, reason reflects the last candidate examined, except that candidates skipped by a plan_id filter leave it untouched. The reasons:

reasonMeaning
no_subscriptionThe user has no subscriptions at all
feature_not_in_planFeature not in the plan's features or grandfathered set, and no agent lease covers it
not_yet_activeFixed starts_at is still in the future
window_elapsedPast expires_at; the worker has not swept it yet
grace_endedIn grace but past grace_ends_at
paused / cancelled / expiredStatus blocks access
usage_exhaustedincluded_minutes fully consumed
no_active_entitlementEvery candidate was skipped by the plan_id filter

Lease expiry

Agent seat leases ride the same sweep. A one-minute lease was created and left alone; within a worker cycle of its expires_at the lease flipped to expired, the parent subscription's seats.active_agent_leases emptied, and the agent-qualified check flipped from allowed: true (with lease_id) to feature_not_in_plan. Manual lease revoke has the same effect immediately. lapses run also reports these transitions in its leases array.

The exact request and response shapes are in the Subscription API reference.