> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gpuoutlet.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Quoting and renting

> Pricing a rental before you commit, every field on the create call, and each way it can be refused.

Renting is two calls: an optional quote, then the create.

## Quote first

```bash theme={null}
curl -s https://api.gpuoutlet.ai/v1/rental-quotes \
  -H "Authorization: Bearer $GPUOUTLET_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "offer_id": "off_3kd91mz7", "gpu_count": 2, "service_tier": "on_demand", "hours": 6 }'
```

```json theme={null}
{
  "offer_id": "off_3kd91mz7",
  "gpu_count": 2,
  "service_tier": "on_demand",
  "currency": "usd",
  "price_per_hour_cents": 498,
  "hold_cents": 498,
  "estimated_total_cents": 2988,
  "available": true,
  "sufficient_balance": true
}
```

Requires `rentals:read`. It reads your balance to fill in `sufficient_balance`,
which is why it is not merely `catalog:read` — and it changes nothing, which is
why it is not `rentals:write`.

<ParamField body="offer_id" type="string" required>
  From [`/offers`](/api/offers).
</ParamField>

<ParamField body="gpu_count" type="integer" default="1">
  Must be at least 1, and no more than the offer's `max_gpus`. An out-of-range
  value is **rejected**, not silently clamped — quietly pricing a different
  rental than the one you asked about would be worse than an error.
</ParamField>

<ParamField body="service_tier" type="'on_demand' | 'spot'" default="on_demand" />

<ParamField body="hours" type="number">
  Planned duration, used only to compute `estimated_total_cents`. It does not
  cap, schedule or reserve anything — a rental runs until you stop it.
</ParamField>

<ResponseField name="price_per_hour_cents" type="integer" required>
  For the **whole rental** at this size — unlike `/offers`, which quotes per GPU.
  This is the number to show a user.
</ResponseField>

<ResponseField name="hold_cents" type="integer" required>
  What we reserve on your balance when the rental starts. Not a charge and not a
  cap — see [Holds](/api/rental-billing#the-hold).
</ResponseField>

<ResponseField name="available" type="boolean" required>
  Whether this can start right now. A snapshot, not a reservation.
</ResponseField>

<ResponseField name="sufficient_balance" type="boolean" required>
  Whether your balance covers `hold_cents` at quote time.
</ResponseField>

<Warning>
  **A quote reserves nothing and costs nothing.** Capacity and price can both move
  between the quote and the create. `available: true` can still lose the race, and
  your create can still come back `offer_unavailable`. Treat a quote as an estimate
  to show a human, never as a promise to your scheduler.
</Warning>

Skip the quote entirely in an automated path — it is a round trip that guarantees
nothing. Its value is in a UI, where a person is about to click a button and
deserves to see the number first.

## Create

```bash theme={null}
curl -s https://api.gpuoutlet.ai/v1/rentals \
  -H "Authorization: Bearer $GPUOUTLET_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
        "offer_id": "off_3kd91mz7",
        "gpu_count": 2,
        "service_tier": "on_demand",
        "name": "sweep-run-14",
        "template_id": "pytorch-2.4-cuda12",
        "ssh_public_key": "ssh-ed25519 AAAAC3Nz…"
      }'
```

Requires `rentals:write` and a live key. **`Idempotency-Key` is mandatory** —
see [Idempotency](/api/idempotency).

<ParamField body="offer_id" type="string" required />

<ParamField body="gpu_count" type="integer" default="1">
  At least 1, at most the offer's `max_gpus`.
</ParamField>

<ParamField body="service_tier" type="'on_demand' | 'spot'" default="on_demand">
  `spot` is cheaper and can be reclaimed at any time, ending the rental with
  `termination_reason: preempted`. Not for work that cannot be interrupted.
</ParamField>

<ParamField body="name" type="string">
  Up to 64 characters. Your label, shown back to you here and in the dashboard.
  Worth setting — `sweep-run-14` beats `rnt_8h2k4m` when you are looking at a
  list of nine machines wondering which one to stop.
</ParamField>

<ParamField body="template_id" type="string">
  The environment to boot. Omit for the default image. An unknown id is
  `400 invalid_template`.
</ParamField>

<ParamField body="ssh_public_key" type="string">
  Authorised on the machine **in addition to** the keys already on your account.

  Supply one when the rental is driven by automation that should not depend on
  dashboard state: a CI job that generates an ephemeral keypair per run does not
  want its access to hinge on which keys someone left in the account settings.
</ParamField>

The response is a [rental](/api/rental-lifecycle) with `status: "provisioning"`
and `access: null`. It is not ready.

## Every way it can be refused

| Status | Code                         | What to do                                    |
| ------ | ---------------------------- | --------------------------------------------- |
| 400    | `idempotency_key_required`   | Send the header                               |
| 400    | `invalid_template`           | Fix or omit `template_id`                     |
| 402    | `insufficient_balance`       | Top up                                        |
| 402    | `account_past_due`           | Settle the balance                            |
| 402    | `key_spend_limit_exceeded`   | Wait for `resets_at`, or use another key      |
| 403    | `insufficient_scope`         | The key lacks `rentals:write`                 |
| 403    | `test_mode_not_supported`    | Use a live key                                |
| 404    | `offer_not_found`            | The offer is gone; re-read the catalog        |
| 409    | `offer_unavailable`          | **This offer** cannot serve you; pick another |
| 409    | `max_active_rentals_reached` | Stop something first                          |
| 409    | `idempotency_key_in_flight`  | Wait, retry with the **same** key             |
| 409    | `idempotency_key_reused`     | New key for a new rental                      |
| 429    | `rate_limited`               | Back off                                      |
| 503    | `capacity_unavailable`       | **Not** offer-specific; retry with backoff    |

The distinction that matters most is **409 `offer_unavailable`** versus
**503 `capacity_unavailable`**:

* `offer_unavailable` is about *this* offer — no free capacity at this size, or
  the seller withdrew it. Retrying it will keep failing. Pick another offer.
* `capacity_unavailable` is ours and temporary — capacity we expected was not
  there. No rental was created. Retry the same request with backoff.

<Note>
  Every 402 is a `billing_error`: well-formed, permitted, unpayable. Retrying
  unchanged will never succeed. This is the single most common mishandled case in
  a scheduler, because a 402 looks transient and is not.
</Note>

## A create that handles reality

```python theme={null}
import uuid, time, random

def rent(offer_ids, **body):
    """Try each candidate offer in turn; give up when none can serve us."""
    for offer_id in offer_ids:
        key = str(uuid.uuid4())          # one key per intent — outside the retry loop
        for attempt in range(4):
            r = session.post(f"{API}/rentals",
                             headers={"Idempotency-Key": key},
                             json={"offer_id": offer_id, **body})
            if r.ok:
                return r.json()

            err = r.json()["error"]
            code = err["code"]

            if code in ("offer_unavailable", "offer_not_found"):
                break                     # this offer is out — next candidate
            if err["type"] == "billing_error":
                raise OutOfBudget(err)    # retrying will never help
            if code in ("idempotency_key_in_flight", "capacity_unavailable", "rate_limited"):
                time.sleep(err.get("retry_after_seconds", 2 ** attempt) + random.random())
                continue                  # same key: that is the point
            raise ApiError(err)           # scope, template, ceiling — all terminal

    raise NoCapacity(offer_ids)
```

Note what it does with the idempotency key: **the same key across retries of one
offer, a fresh key for the next offer.** Retrying with a new key would risk two
machines; carrying one key across different offers would earn
`idempotency_key_reused`, since the body changed.
