---
title: "Device sign-in"
description: "Sign-in for command line tools and MCP clients, approved by a person in the dashboard.  The only endpoints that take no API key."
canonical: "https://writavo.com/docs/api/device-sign-in"
last-updated: "2026-09-30"
---

# Device sign-in

Sign-in for command line tools and MCP clients, approved by a person in the dashboard.

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.

`flow: "oauth"` is reserved for Writavo's own hosted MCP server, which runs this sign-in
on a person's behalf behind its OAuth consent. Sent by anything else it is refused with
`403 FORBIDDEN`: leave `flow` out.

**Parameters**

| Name | In | Type | Required | Description |
|---|---|---|---|---|
| `X-Writavo-Mcp-Worker` | header | string | no | Sent only by Writavo's hosted MCP server, and required with `flow: "oauth"`. You do not have one and do not need one: leave it out and leave `flow` out. |

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `flow` | string | no | `device` (the default) for a command line tool. `oauth` only from Writavo's hosted MCP server, with its `X-Writavo-Mcp-Worker` header; from anything else it is `403 FORBIDDEN`. |
| `redirect_host` | string | null | no | `oauth` only. The host of the AI client's registered redirect URI, shown prominently on the consent screen so the person can recognise where access is going. Sent with `flow: device` it is `422 VALIDATION_FAILED`. |
| `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`. Bad JSON, an unusable query parameter value, or a missing required header. |
| 403 | - | `FORBIDDEN`, or `INSUFFICIENT_SCOPE` for a publishable key. Refused for a reason that no scope would change; the message says which and what to do instead. |
| 409 | - | The request collides with the current state. Read `code`: - `SLUG_CONFLICT` - the slug is taken. - `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT` - key reused with another body, or still running. - `CONFLICT` - the object changed under you. Re-read and retry. - `APPROVAL_INVALID` - the `Writavo-Approval` is expired, used, unknown, for another request, or its effect changed. Retry without it for a new approval. - `PREREQUISITE_MISSING` / `FEATURE_UNAVAILABLE` (settings, delivery, SEO) - something is not set up (the message names what sets it up), or Writavo has it switched off (the message names the free alternative). Nothing was done or charged. |
| 422 | - | `VALIDATION_FAILED`. The values are not acceptable; `error.fields` maps each field to a message. Common causes: publishing without a title, slug or content; scheduling in the past; sending `status` on a create or update. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. The two device sign-in endpoints take no key, so they are limited per IP address. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| `POST /comments` adds three limits, named in `error.limit` on its 429: `reader` (5 per reader in 10 minutes, by `reader_ip`), `site` (300 per Site per hour) and `queue` (2,000 awaiting review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. |
| 500 | - | `INTERNAL_ERROR`. Our fault. Quote the `request_id` from the body if you contact support. Safe to retry; send the same `Idempotency-Key` so a request that did succeed is not repeated. |
| 503 | - | `MAINTENANCE`. Writes are paused, platform wide or for your Site. Reads usually work and your published blog stays up. Retry after `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`. Bad JSON, an unusable query parameter value, or a missing required header. |
| 404 | - | `NOT_FOUND`. No such object, or it belongs to another Site. **The two are deliberately indistinguishable and never return 403.** If the id is right, check you are using the right Site's key. |
| 422 | - | `VALIDATION_FAILED`. The values are not acceptable; `error.fields` maps each field to a message. Common causes: publishing without a title, slug or content; scheduling in the past; sending `status` on a create or update. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. The two device sign-in endpoints take no key, so they are limited per IP address. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| `POST /comments` adds three limits, named in `error.limit` on its 429: `reader` (5 per reader in 10 minutes, by `reader_ip`), `site` (300 per Site per hour) and `queue` (2,000 awaiting review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. |
| 500 | - | `INTERNAL_ERROR`. Our fault. Quote the `request_id` from the body if you contact support. Safe to retry; send the same `Idempotency-Key` so a request that did succeed is not repeated. |
| 503 | - | `MAINTENANCE`. Writes are paused, platform wide or for your Site. Reads usually work and your published blog stays up. Retry after `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>"
}'
```

### POST /auth/key/revoke-oauth

Internal: revoke the key behind a revoked MCP sign-in

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

**Internal. Only the Writavo MCP service can call this; anyone else gets `403`.** When an AI
client signs out of the hosted MCP server (it revokes its OAuth refresh token, RFC 7009),
the MCP service calls this so the Writavo key behind that connection is revoked too and
disappears from Settings > AI agents. It acts on OAuth-origin keys only, is idempotent, and
can only ever end access, never grant it. To sign a key out yourself, use
`POST /auth/key/revoke` with that key.

**Request body** (required)

| Field | Type | Required | Description |
|---|---|---|---|
| `key_id` | uuid | yes |  |

**Responses**

| Status | Data | Description |
|---|---|---|
| 200 | object | Done. `revoked` is false when there was nothing live to revoke. |
| 403 | - | `FORBIDDEN`, or `INSUFFICIENT_SCOPE` for a publishable key. Refused for a reason that no scope would change; the message says which and what to do instead. |
| 422 | - | `VALIDATION_FAILED`. The values are not acceptable; `error.fields` maps each field to a message. Common causes: publishing without a title, slug or content; scheduling in the past; sending `status` on a create or update. |
| 429 | - | `RATE_LIMIT_EXCEEDED`. Back off and honour `Retry-After`. Limits are per key, per minute, by endpoint class. The two device sign-in endpoints take no key, so they are limited per IP address. \| Class \| Endpoints \| Limit \| \|---\|---\|---\| \| read \| every GET \| 600 per minute \| \| write \| POST, PATCH and DELETE on content, taxonomy, keys, webhooks, the content plan, formats and prompts, settings, delivery, SEO curation, the team and billing \| 120 per minute \| \| upload \| `POST /media/upload-url` and `POST /seo/backlinks/import` \| 60 per minute \| \| pipeline \| `POST /pipeline/runs`, `POST /pipeline/articles/{id}/requeue`, `POST /delivery/cms/push` and the seven paid SEO scans \| 10 per minute \| \| auth \| `POST /auth/device` and `POST /auth/device/token`, per IP address \| 60 per minute \| `POST /comments` adds three limits, named in `error.limit` on its 429: `reader` (5 per reader in 10 minutes, by `reader_ip`), `site` (300 per Site per hour) and `queue` (2,000 awaiting review). Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`. |
| 500 | - | `INTERNAL_ERROR`. Our fault. Quote the `request_id` from the body if you contact support. Safe to retry; send the same `Idempotency-Key` so a request that did succeed is not repeated. |
| 503 | - | `MAINTENANCE`. Writes are paused, platform wide or for your Site. Reads usually work and your published blog stays up. Retry after `Retry-After`. |

```bash
curl -X POST https://api.writavo.com/v1/auth/key/revoke-oauth \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -d '{
  "key_id": "3f1b0c7a-0000-4000-8000-000000000001"
}'
```
