# Device sign-in

Device sign-in for command line tools and MCP clients: a person approves access in the
dashboard instead of copying a key. The only endpoints that take no API key.

Base URL: `https://api.writavo.com/v1`

### POST /auth/device

Start a device sign-in

- **Operation id**: `startDeviceAuthorization`
- **Scope**: `none`
- **Permission**: `none`
- **Rate limit class**: auth
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

Starts a sign-in for a command line tool or MCP client, modelled on the OAuth device
authorization grant (RFC 8628). Takes no API key: this is how a client comes to have one.

**The client generates the key.** Create a secret the same shape as ours, `wv_sk_`
followed by 32 base64url characters (24 random bytes), keep it private, and send only its
SHA-256 as `key_hash` plus its first 12 characters as `key_prefix`. The secret never
leaves your machine; approval registers its hash as an ordinary secret key.

Show the person `verification_uri_complete` (or `verification_uri` and `user_code`). They
sign in to the dashboard, or create an account first, choose a Site, and approve. Then
poll `POST /auth/device/token` every `interval` seconds until it says `approved`, and
start using your key. The request expires after `expires_in` seconds.

The key is created for the approving person, on the Site they choose, with the scopes you
asked for that they are allowed to grant, and expires after 90 days. It appears under
Settings > API keys, where it can be revoked like any other.

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `client_name` | string | yes | What the person sees on the approval screen, such as the name of your tool. |
| `client_host` | string | null | no | The machine asking, such as its hostname, so the person can recognise it. |
| `key_hash` | string | yes | SHA-256 of the full secret key you generated, as lower-case hex. |
| `key_prefix` | string | yes | The first 12 characters of that key, shown in the dashboard to identify it. |
| `scopes` | Scope[] | yes | The scopes you want. The key receives those the approving person may grant on the Site they pick; the rest are dropped. |

**Responses**

| Status | Data | Description |
|---|---|---|
| 201 | object | The sign-in is waiting for approval. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 409 | - | `SLUG_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_KEY_IN_FLIGHT` or `CONFLICT`. The request is valid but collides with the current state: a slug is taken, an idempotency key was reused with a different body or is still in flight, or the object moved while you were working on it. Read `code` to tell which, then re-read and retry. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl -X POST https://api.writavo.com/v1/auth/device \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
  "client_name": "<client_name>",
  "key_hash": "<key_hash>",
  "key_prefix": "<key_prefix>",
  "scopes": "<scopes>"
}'
```

### POST /auth/device/token

Poll a device sign-in

- **Operation id**: `pollDeviceAuthorization`
- **Scope**: `none`
- **Permission**: `none`
- **Rate limit class**: auth
- **Plan feature**: `none`
- **Spends credits**: no
- **Publishable key may call it**: no

Reports whether the person has approved the sign-in. It returns an outcome, never a
credential: on `approved`, the key you generated is live, and the response says which
Site it belongs to, which scopes it carries and when it expires.

`pending` means keep waiting. `slow_down` means you polled faster than `interval`; wait
the `interval` it returns before the next poll. `denied` and `expired` are final: start
again with a new key.

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `device_code` | string | yes |  |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | object | The current outcome. |
| 400 | - | `INVALID_REQUEST`. The request could not be parsed, or a parameter is not usable: bad JSON, an unknown query parameter value, or a missing required header. |
| 404 | - | `NOT_FOUND`. Either no such object exists, or it exists and belongs to a different Site. **These two cases are deliberately indistinguishable, and neither ever returns 403.** A 403 would confirm the object exists, which would let anyone with a valid key enumerate other customers' content by id. If you are certain the id is right, check you are using the key for the correct Site. |
| 422 | - | `VALIDATION_FAILED`. The request parsed but the values are not acceptable. `error.fields` maps each offending field to a message you can put next to the input. Common causes: publishing without a title, slug or content; scheduling in the past; and sending `status` on a create or update, which is how the API refuses to let a client push content into the AI pipeline. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. Writes are limited harder than reads because they cost more to serve, and pipeline runs hardest of all because they cost real money. The two device sign-in endpoints take no key, so they are limited per IP address instead. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys and webhooks \| 120 per minute \| \| upload \| `POST /media/upload-url` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs` \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`, so you can slow down before you are refused rather than after. |
| 500 | - | `INTERNAL_ERROR`. Something failed on our side. The message is deliberately generic; the detail is in our logs against the `request_id` in the body, so quote it if you contact support. Safe to retry, and safer still with the same `Idempotency-Key`, which guarantees you do not create a second object if the first request actually succeeded. |
| 503 | - | `MAINTENANCE`. Writes are paused, either platform wide or for your Site. Reads usually keep working, and your published blog is served from cache and stays up. Retry after the window given in `Retry-After`. |

```bash
curl -X POST https://api.writavo.com/v1/auth/device/token \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
  "device_code": "<device_code>"
}'
```
