---
title: Lease lifecycle
description: The states a lease moves through, the two clocks that end it, the budgets that bound it, and the event stream that records all of it.
type: explanation
product: sandboxes
summary: How a lease lives, why it ends, and how to watch it happen
updated: 2026-09-28
order: 0
---

A lease is granted, runs, and ends. Everything in between is visible: the
state is on the lease, the ends are typed, and every step is an event you can
read in order or long-poll for.

## The states

`status.state` is one of seven:

- `requested` — the call was accepted and a machine is being assigned.
- `granted` — a machine is assigned, and the guest is booting.
- `ready` — the guest answers. Everything in the data plane needs this state.
- `paused` — the disk is kept and the machine is destroyed.
- `ending` — the end has begun, and `status.end_reason` is set.
- `ended` — the lease is finished. It never returns to another state.
- `refused` — no machine was granted, and `status.refusal` says why.

`status.conditions` carries the platform's own view beside the state: `Ready`,
`Reconciling` and `Stalled`, each with `status`, `reason` and a
customer-safe `message`.

## The two clocks

A lease has two ways to run out, and either ends it.

`ttl` is the maximum wall time from the grant, and it is required. It runs
from one minute up to the longest lease the shape allows — every shape in the
catalog today caps it at 24 hours, and each one reports its own bounds in
`min_lease_duration` and `max_lease_duration`. Reaching it ends the lease with
the `expired` reason. `renew` moves
`expire_time` out, never past the shape's longest lease, and does not change
the lease's generation:

```bash
sylphx sandboxes leases renew orgs/acme/projects/shop/envs/production/leases/lease --ttl 1h
```

`idle_timeout` is the other clock: no data-plane call, no stream input and no
`exec` for that long ends the lease IDLE. Leave it unset and an unused lease
lives until its `ttl`.

The two are complementary rather than redundant. A `ttl` bounds what a lease
may cost; an `idle_timeout` bounds what an abandoned one may cost. A lease
that serves an interactive user wants both.

## Pause and resume

`pause` destroys the machine and keeps the disk. Process memory is not kept,
so a resumed lease starts its processes again. The lease's generation
increases on the pause and again on the resume, which is what invalidates
every stream token and in-flight Cell command from before.

`resume` asks for a machine now, exactly as `create` does — including the
possibility of a refusal when nothing can be granted.

## Budgets

Beyond the clocks, a lease can carry a budget, and reaching it ends the lease
for that reason:

<PropertyTable
	properties={[
		{
			name: 'max_cpu_seconds',
			type: 'int64',
			description: 'Ends the lease CPU_BUDGET after this many vCPU-seconds of guest CPU time.',
		},
		{
			name: 'max_cost_micros',
			type: 'int64',
			description: 'Ends the lease COST_BUDGET once its accrued cost at the shape’s list price reaches the cap. [The price list](/pricing) is where those prices live.',
		},
	]}
/>

A budget is a bound, not a target: a lease that hits one is ended, and the
stream carries `budget_warning` before the end so a caller can react first.

## Why a lease ended

`status.end_reason` is typed, and the event stream carries it too. A lease
ended for one of ten reasons:

- `released` — a caller ended it.
- `expired` — it reached its `ttl`.
- `idle` — it went unused past its `idle_timeout`.
- `cpu_budget` — it reached `max_cpu_seconds`.
- `cost_budget` — it reached `max_cost_micros`.
- `abuse` — the platform stopped it.
- `quota` — an org limit stopped it.
- `org_deleted` — the org the lease belonged to went away.
- `machine_lost` — the machine it ran on was lost.
- `boot_failed` — the guest never became ready.

The distinction between them is the point of the field: a caller that retries
on `machine_lost` and reports a bug on `boot_failed` behaves differently from
one that retries everything. `usage` on the ended lease says what it consumed
— wall seconds, guest vCPU-seconds, egress bytes and the rest.

## The event stream

Every step is a `LeaseEvent`, in order, and the stream is the lease's own
history rather than a log you have to reconstruct:

```bash
sylphx sandboxes lease-events list orgs/acme/projects/shop/envs/production/leases/lease
```

```bash
curl "https://api.sylphx.com/v1/orgs/acme/projects/shop/envs/production/leases/lease/lease_events" \
  -H "Authorization: Bearer $SYLPHX_API_KEY"
```

```ts
const response = await sylphx.sandboxes.leaseEvents.list({
  parent: 'orgs/acme/projects/shop/envs/production/leases/lease',
})
```

An event's `kind` is one of twelve: `granted`, `ready`, `refused`, `ended`,
`renewed`, `network_changed`, `control_acquired`, `control_released`,
`control_expired`, `budget_warning`, `paused` or `resumed`. `sequence` is the
position in the lease's own sequence, from 1, so a reader can tell a gap from
a quiet spell. Each event also carries the `state` and the `lease_generation`
after it, the `principal` that caused it when a caller did, and — on an
`ended` event — the `end_reason` and the final `usage`.

`list` is a long poll. With `wait` it blocks for an event after the
`page_token` you already hold, for at most 60 seconds, which turns the stream
into a loop that reacts instead of one that polls on a timer:

```bash
sylphx sandboxes lease-events list \
  orgs/acme/projects/shop/envs/production/leases/lease \
  --wait 30s
```

A lease can also deliver its own events as signed webhooks: `spec.webhook`
names an HTTPS `uri` and, optionally, the `kinds` to receive. The URI earns
its keep when the thing waiting for an event is not the thing holding the
connection.

## Generations

Two numbers guard against acting on a lease that has moved on.

`meta.generation` increases on every change to `spec`; `status.lease_generation`
increases on grant, pause, resume and end. Every stream token and every Cell
command carries the latter, and a call that names a generation the lease has
left is refused `STALE_GENERATION` rather than applied to a machine that is no
longer the one you meant.

The practical shape of it: a token minted before a pause cannot be replayed
against the machine after the resume, and a command written for generation 3
cannot land on generation 4. Conditional writes work the same way through
`meta.etag` — send it back and a stale one fails `ETAG_MISMATCH` instead of
overwriting a change you have not seen.

<RelatedDocs
	links={[
		{
			href: '/docs/api/leases',
			label: 'The leases collection',
			description: 'Every method, its scope and its examples.',
		},
		{
			href: '/docs/api/lease_events',
			label: 'The lease_events collection',
			description: 'Every event field, and the long poll.',
		},
		{
			href: '/docs/sandboxes/quickstart',
			label: 'Quickstart',
			description: 'Create a lease, run a command, and release it.',
		},
	]}
/>
