---
name: screvi-api
description: >
  Interact with the Screvi Public API to read and write a user's book highlights,
  saved articles, sources and tags. Use when the user wants to query, search,
  save, tag, or organise their reading in Screvi.
license: MIT
compatibility: Requires an API key from Screvi account settings. REST API over HTTPS.
metadata:
  author: screvi
  version: "2.0"
---

# Screvi Public API

Screvi is a highlights manager, read-later app and digital commonplace book. The Public API
covers highlights, sources (books, podcasts, videos, tweets), saved articles, tags, and
semantic search. Reads need a key; writes need a key with the `write` scope.

**Base URL:** `https://api.screvi.com/api/v1`
**Authentication:** `X-API-Key: sk_live_...` or `Authorization: Bearer sk_live_...` on every request.
**Rate limit:** 100 requests per minute per API key.
**Docs:** `https://api.screvi.com/api/docs` (OpenAPI at `/api/docs.json`).

If the user would rather talk to their library from an AI assistant than write code, point them
at the MCP server at `https://api.screvi.com/mcp` instead.

## Authentication

Keys are created under Settings → API in Screvi and carry scopes:

| Scope   | Grants                        |
|---------|-------------------------------|
| `read`  | All `GET` endpoints           |
| `write` | `POST`, `PATCH`, `DELETE`     |

Missing or invalid keys return `401`. A key without the needed scope returns `403` with
`code: "insufficient_scope"`.

The older account-wide key (no `sk_live_` prefix) still works for reads but is permanently
read-only, because the same secret appears in email links and on Kobo devices. Requests using
it return a `Deprecation` header, and any write attempt returns `403` with
`code: "legacy_key_read_only"`. If you hit that, tell the user to create a scoped key with
write access.

## Endpoints

### Highlights

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/highlights` | List, newest first. Filters: `source_id`, `favorite`, `tag`, `tag_id`, `updated_since`, `include_deleted` |
| GET | `/highlights/random` | One random highlight. Add `count` (max 20) for an array; filters: `favorite`, `types`, `tag`, `source_id` |
| GET | `/highlights/{id}` | One highlight with source and tags |
| GET | `/search` | Hybrid semantic + keyword search. `q` required; filters: `source`, `tag`, `types`, `favorite`, `min_relevance` |
| POST | `/highlights` | Create one (write). Optional `tags` by name |
| POST | `/highlights/bulk` | Create up to 100 in one transaction (write) |
| PATCH | `/highlights/{id}` | Edit `content`, `note`, `favorite`, `url`, `location`, `date`, `metadata`, or replace `tags` (write) |
| DELETE | `/highlights/{id}` | Soft delete (write) |
| POST | `/highlights/{id}/tags` | Add tags without touching existing ones (write) |
| DELETE | `/highlights/{id}/tags` | Remove the named tags (write). Body: `{ "tags": [...] }` |

### Articles

Saved web pages, newsletters and PDFs. This is where "save this link", "what's in my inbox"
and "read me that article" live.

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/articles` | List. Filters: `q`, `status`, `home_status`, `favorite`, `has_highlights`, `tag`, `saved_since`, `saved_before`, `updated_since`, `include_deleted`, `sort_by`, `order` |
| GET | `/articles/{id}` | Full article with body. `format=text` for plain text; page with `offset` and `max_chars` |
| POST | `/articles` | Save a URL (write). Returns immediately with `parse_state: queued`; read it a few seconds later |
| PATCH | `/articles/{id}` | Triage and metadata (write): `home_status` (`inbox`/`later`/`archive`), `favorite`, `title`, `excerpt`, `note`, `tags` |
| POST | `/articles/{id}/tags` | Add tags (write) |
| DELETE | `/articles/{id}/tags` | Remove tags (write) |

`status` is reading progress (`unread`, `reading`, `read`, `archived`). `home_status` is where
the article sits in the app (`inbox`, `later`, `archive`) and is what you change to triage.

### Sources

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/sources` | List with highlight counts. Filters: `type`, `search`, `updated_since`, `include_deleted`, `include_empty` |
| GET | `/sources/{id}` | One source with paginated highlights |
| POST | `/sources` | Create a `book`, `podcast`, `video`, `tweet`, `self` or `custom` source (write) |
| PATCH | `/sources/{id}` | Edit metadata; `type` is immutable (write) |
| DELETE | `/sources/{id}` | Soft delete; `?cascade=true` to also delete its highlights (write) |

### Tags

| Method | Path | Purpose |
|--------|------|---------|
| GET | `/tags` | All tags with highlight and article counts |
| POST | `/tags` | Create by name (write). Returns the existing tag with `200` if the name already exists |
| PATCH | `/tags/{id}` | Rename and/or recolor (write). `422` if the new name clashes |
| DELETE | `/tags/{id}` | Delete the tag and detach it everywhere; highlights and articles stay (write). Hard delete |

Everywhere a `tags` array is accepted, entries may be tag **names** (matched case-insensitively,
created when new) or tag **ids**.

## Response shapes

List endpoints return `{ "data": [...], "pagination": { page, per_page, total, total_pages, has_more } }`.
Single-item endpoints return `{ "data": { ... } }`.

A highlight:

```json
{
  "id": "uuid",
  "content": "The highlight text",
  "note": "Optional user note",
  "favorite": false,
  "url": null,
  "location": 42,
  "date": "2025-01-15T10:00:00Z",
  "created_at": "2025-01-15T10:00:00Z",
  "updated_at": "2025-01-15T10:00:00Z",
  "tags": [{ "id": "uuid", "name": "philosophy", "color": "#115e59" }],
  "source": { "id": "uuid", "name": "Meditations", "type": "book", "author": "Marcus Aurelius", "image_url": "https://...", "url": null }
}
```

An article (list shape; `GET /articles/{id}` adds `content`, `content_length`,
`content_offset`, `content_truncated`, `parse_error`, `highlights`):

```json
{
  "id": "uuid",
  "title": "Deep Work",
  "author": "Cal Newport",
  "url": "https://...",
  "status": "unread",
  "home_status": "inbox",
  "favorite": false,
  "reading_time_minutes": 12,
  "word_count": 2400,
  "source_domain": "example.com",
  "saved_at": "2026-09-01T10:00:00Z",
  "parse_state": "parsed",
  "highlight_count": 3,
  "tags": [{ "id": "uuid", "name": "focus", "color": "#115e59" }],
  "screvi_url": "https://app.screvi.com/articles/uuid"
}
```

Search results are highlights plus `similarity` (0-1 cosine score, null for keyword-only hits),
`match_type` (`semantic`, `keyword`, or `both`) and `relevance` (fused rank score).

## Common patterns

### Fetch everything (pagination loop)

```
page = 1
loop:
  GET /highlights?page={page}&per_page=100
  process data
  if !pagination.has_more: break
  page += 1
```

### Incremental sync

Store the latest `updated_at` from your last run, then:

```
GET /highlights?updated_since=2025-06-01T00:00:00Z&per_page=100&include_deleted=true
GET /sources?updated_since=2025-06-01T00:00:00Z&per_page=100&include_deleted=true
GET /articles?updated_since=2025-06-01T00:00:00Z&per_page=100&include_deleted=true
```

**Always pass `include_deleted=true` when syncing.** Deleted rows are returned once more with
`deleted: true` so you can remove your local copy. Without it, deletions are invisible and your
mirror drifts permanently out of date.

### Save a link, then read it

```
POST /articles
{ "url": "https://example.com/post", "tags": ["to-read"] }
→ 201 { "data": { "id": "...", "parse_state": "queued", "duplicate": false } }

(wait a few seconds)

GET /articles/{id}?format=text
→ content is the plain-text body; if still null, check parse_state and retry
```

A `200` with `duplicate: true` means the URL was already saved; `id` is the existing article.

### Triage the inbox

```
GET /articles?home_status=inbox&sort_by=saved_at&order=asc&per_page=50
GET /articles?saved_since=2026-09-01T00:00:00Z&home_status=inbox     # "everything from the last 3 days"
PATCH /articles/{id}   { "home_status": "archive" }
PATCH /articles/{id}   { "home_status": "later" }
PATCH /articles/{id}   { "favorite": true, "tags": ["keeper"] }
```

### Writing highlights

```
POST /highlights
{ "content": "...", "source_id": "<uuid>", "note": "optional", "tags": ["stoicism"] }
```

Attach to a source with `source_id`, or to a saved article with `article_id`, never both.
Omit both for a standalone highlight. Importing more than a couple at once? Use the bulk
endpoint, which counts as a single request against the rate limit:

```
POST /highlights/bulk
{ "highlights": [ { "content": "...", "tags": ["x"] }, { "content": "..." } ] }
```

### Semantic search with filters

```
GET /search?q=decision making under pressure&types=book,article&tag=leadership
```

Returns highlights ranked by meaning and keyword together, not keyword alone.

### Daily review

```
GET /highlights/random?count=3&favorite=true
```

## Source types

| Type      | Description                              |
|-----------|------------------------------------------|
| `book`    | Kindle, Kobo, Apple Books, etc.          |
| `article` | Saved web pages and newsletters          |
| `podcast` | Podcast highlights                       |
| `video`   | Video highlights (includes legacy `youtube`) |
| `tweet`   | Twitter/X bookmarks                      |
| `self`    | User-created highlights                  |
| `custom`  | Custom collections                       |
| `pdf`     | PDF document annotations                 |

Only `book`, `podcast`, `video`, `tweet`, `self` and `custom` can be created with `POST /sources`.
Web articles are created with `POST /articles`.

## Error handling

All errors return `{ "error": "message", "code": "machine_readable_code" }`. Branch on `code`,
never on the message text.

| Status | Code                     | Meaning                                          |
|--------|--------------------------|--------------------------------------------------|
| 400    | `invalid_parameter`      | Bad query-string value; `fields` says which      |
| 401    | `missing_api_key`        | No key supplied                                  |
| 401    | `invalid_api_key`        | Unknown or revoked key                           |
| 403    | `legacy_key_read_only`   | Old account key used for a write                 |
| 403    | `insufficient_scope`     | Key lacks the required scope                     |
| 403    | `subscription_required`  | No active subscription                           |
| 404    | `not_found`              | Resource not found (also for malformed ids)      |
| 409    | `conflict`               | e.g. deleting a source that still has highlights |
| 413    | `payload_too_large`      | More than 100 highlights in a bulk request       |
| 422    | `validation_failed`      | Bad request body; `fields` maps each problem     |
| 429    | `rate_limited`           | 100 req/min per key                              |
| 500    | `internal_error`         | Server error                                     |

A `422` looks like:

```json
{
  "error": "Validation failed",
  "code": "validation_failed",
  "fields": { "content": "is required", "favorite": "must be a boolean" }
}
```

## Gotchas

- Writes need a key with the `write` scope. The legacy account key can never write.
- Deletes are **soft**. A deleted highlight still appears in `?include_deleted=true` as a
  tombstone; it is not erased. Articles cannot be deleted through the API at all, only archived.
- `DELETE /sources/{id}` refuses with `409` if the source still has highlights. Pass
  `?cascade=true` to delete them along with it.
- `POST /highlights/bulk` is **all-or-nothing**: one invalid item and nothing is written. The
  `fields` map names the offending index, e.g. `highlights[3].content`.
- `POST /articles` returns before the page is parsed. Poll `GET /articles/{id}` until
  `parse_state` is `parsed` (or `failed`, in which case `parse_error` explains why).
- `PATCH` with `tags` **replaces** the tag set; use `POST /{id}/tags` to add without replacing.
- An article's body is shared between everyone who saved the same page, so it is not editable.
- A source's `type` cannot be changed after creation.
- `per_page` max is **100** for highlights, sources and articles, **50** for search.
- `GET /highlights/random` returns an object by default and an array only when `count` is given.
- `source_id` on `/highlights` matches both traditional sources (books) and articles.
- Pagination uses 1-based page numbers. All timestamps are ISO 8601 in UTC.
