# POST /v1/client-tokens

Issue a short-lived client token

Issues a client token: a short-lived credential that can call `POST /v1/scans` and nothing else. Call it from your server with your API key and hand the token to a browser or an app, which then sends the scan straight to this API without ever holding the key. A token lives 300 seconds and allows 1 scan request unless the body asks otherwise; send `{}` to take the defaults. Its scans are billed and stored exactly as the issuing key's would be, a sandbox key issues sandbox tokens, and revoking or deleting the key ends every token it issued. The token is returned once and only its hash is kept.

Call it from your own server with your API key, then hand the token to the app or the browser. The app sends `POST /v1/scans` with `Authorization: Bearer <token>`, and the secret key never leaves your server.

A token lives 300 seconds unless `expires_in` asks for 10 to 900, and allows one scan request unless `max_uses` asks for up to 10. Every request presented with the token spends a use, whatever its outcome.

A token can call `POST /v1/scans` and nothing else. Its scans are billed, stored and rate-limited exactly as the issuing key's, a sandbox key issues sandbox tokens, and revoking or deleting the key ends every token it issued.

The token is returned once. Only its hash is kept, so it cannot be read back.

## Request

| | |
|---|---|
| Method | `POST` |
| Path | `/v1/client-tokens` |
| Authentication | `Authorization: Bearer <api key>` |

### Body fields

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `expires_in` | integer | no | – | Seconds until the token stops working: 10 to 900, 300 when omitted. |
| `max_uses` | integer | no | – | How many scan requests the token may make: 1 to 10, 1 when omitted. Every request presented with the token uses one, whatever its outcome. |

### Example request body

```json
{
  "expires_in": 300,
  "max_uses": 1
}
```

## Responses

### 201

The new client token.

Body: `ClientToken`.

| Field | Type | Description |
|---|---|---|
| `token` | string | The client token. Send it as `Authorization: Bearer <token>` to `POST /v1/scans`. It is shown once and cannot be read back. |
| `expires_at` | string | When the token stops working (UTC). |
| `max_uses` | integer | How many scan requests the token may make. |
| `sandbox` | boolean | True when the token was issued with a sandbox key: its scans run in the sandbox, exactly as the key's would. |

```json
{
  "token": "ct_live_4f1d0c9a7b2e8f36a5c4d1e0b9a8f7e6d5c4b3a2918f7e6d5c4b3a2918f7e6d5",
  "expires_at": "2026-09-17T09:46:12Z",
  "max_uses": 1,
  "sandbox": false
}
```

### 400

The request is malformed: the body is not JSON or a required part is missing.

Body: `ErrorResponse`.

```json
{
  "error": {
    "code": "invalid_request",
    "message": "Body is not valid JSON.",
    "docs_url": "https://doc.cheap/docs/errors/invalid_request",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}
```

### 401

Missing or malformed `Authorization: Bearer <api key>` header, or an unknown key.

Body: `ErrorResponse`.

```json
{
  "error": {
    "code": "unauthorized",
    "message": "Send your API key as `Authorization: Bearer <key>`.",
    "docs_url": "https://doc.cheap/docs/errors/unauthorized",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}
```

### 403

The credential cannot call this operation: a client token can only call `POST /v1/scans`.

Body: `ErrorResponse`.

```json
{
  "error": {
    "code": "client_token_not_allowed",
    "message": "A client token can only call POST /v1/scans.",
    "docs_url": "https://doc.cheap/docs/errors/client_token_not_allowed",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}
```

### 415

The request body must be sent as `Content-Type: application/json`, and a scan's image must be a JPEG or PNG.

Body: `ErrorResponse`.

```json
{
  "error": {
    "code": "unsupported_media_type",
    "message": "Send the request body as `Content-Type: application/json`.",
    "docs_url": "https://doc.cheap/docs/errors/unsupported_media_type",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}
```

### 422

The body is well-formed JSON but does not satisfy the request schema.

Body: `ErrorResponse`.

```json
{
  "error": {
    "code": "validation_failed",
    "message": "/options/mode: Invalid option: expected one of \"full\"",
    "docs_url": "https://doc.cheap/docs/errors/validation_failed",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}
```

### 429

Rate limit exceeded for this key or IP.

Body: `ErrorResponse`.

```json
{
  "error": {
    "code": "rate_limited",
    "message": "Sandbox limit of 10 requests per hour per IP reached.",
    "docs_url": "https://doc.cheap/docs/errors/rate_limited",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}
```

### 500

Unexpected failure on the server; the scan was not charged.

Body: `ErrorResponse`.

```json
{
  "error": {
    "code": "internal_error",
    "message": "Unexpected error.",
    "docs_url": "https://doc.cheap/docs/errors/internal_error",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}
```

### 503

Temporarily unable to serve the request: a store this service depends on is unreachable. Nothing was charged and it may be retried, after the `Retry-After` seconds where that header is present.

Body: `ErrorResponse`.

```json
{
  "error": {
    "code": "service_unavailable",
    "message": "The service is temporarily unable to handle this request; nothing was charged. Retry shortly.",
    "docs_url": "https://doc.cheap/docs/errors/service_unavailable",
    "request_id": "req_9e6b1f7c-2d4a-4b83-9c51-7f0ad3e8b642",
    "event_id": null
  }
}
```

## Error codes reachable here

| Code | When | Page |
|---|---|---|
| `client_token_not_allowed` | The request was authenticated with something other than an API key – a client token, or a dashboard login. | [client_token_not_allowed](https://doc.cheap/docs/errors/client_token_not_allowed) |
| `internal_error` | The service failed in a way it does not model. No token was issued. | [internal_error](https://doc.cheap/docs/errors/internal_error) |
| `invalid_request` | The body is missing or not readable as JSON. Send `{}` to take the defaults. | [invalid_request](https://doc.cheap/docs/errors/invalid_request) |
| `rate_limited` | The caller is over the rate limit for its key kind. | [rate_limited](https://doc.cheap/docs/errors/rate_limited) |
| `service_unavailable` | The store tokens are kept in is unreachable, so no token was issued; retry after the `Retry-After` seconds. | [service_unavailable](https://doc.cheap/docs/errors/service_unavailable) |
| `unauthorized` | The `Authorization` header is missing, malformed or names an unknown key. | [unauthorized](https://doc.cheap/docs/errors/unauthorized) |
| `unsupported_media_type` | The body was sent as something other than `application/json`. | [unsupported_media_type](https://doc.cheap/docs/errors/unsupported_media_type) |
| `validation_failed` | `expires_in` is not a whole number from 10 to 900, `max_uses` is not a whole number from 1 to 10, or the body has another field. | [validation_failed](https://doc.cheap/docs/errors/validation_failed) |

Every error body carries the same shape; the full list is on [Errors](https://doc.cheap/docs/reference/errors).
