# Paging, projections and safe retries

Four mechanics that decide whether an integration is correct under load: how you page, how much you ask for, how you retry, and how you avoid overwriting someone else's edit.

## Cursor pagination

The shape of `data` on every list endpoint. Paging is cursor based rather than offset
based, so rows are never skipped or repeated when content changes mid pagination.

The opaque cursor from `data.next_cursor` on the previous page. Do not parse it or
construct one; its encoding is not part of this contract and will change.

**The first page** (returns 200)

```bash

curl https://api.writavo.com/v1/articles?limit=2&fields=id,title,status,updated_at \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"

```

Cursor paging, so rows are never skipped or repeated when content changes mid pagination.

**The next page** (returns 200)

```bash

curl https://api.writavo.com/v1/articles?limit=2&fields=id,title,status,updated_at&cursor=eyJ2IjoxLCJrIjpbIjIwMjYtMDgtMDZUMTA6MDA6MDBaIl19 \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000"

```

Pass back `data.next_cursor` verbatim. Do not parse it or construct one.

> There is no total count, on purpose. Counting a growing table on every list request is the kind of query that gets slower exactly as a customer succeeds. Page until `next_cursor` is `null`.

Page size. Values above the maximum are clamped rather than rejected, so a client asking
for a thousand rows gets a hundred and a `next_cursor`.

## Incremental sync

To keep a copy of your content in step without re-reading everything, store the greatest `updated_at` you have seen and pass it back as `updated_since`. Combine it with the default ordering, `updated_at.desc`, and page until the cursor runs out.

pattern:

```
# first run
GET /articles?limit=100&order=updated_at.asc

# every run after that
GET /articles?limit=100&order=updated_at.asc&updated_since=2026-08-06T09:41:12Z
```

Webhooks are the better answer when you want to react quickly. Polling is the right answer when you want to be sure you have everything, including anything a delivery failure lost. Most integrations do both.

## Sparse fieldsets

List endpoints omit article bodies by default. A list endpoint that returns every body is the classic way to make a content API slow and expensive, so `content` is something you ask for one article at a time, or explicitly with a small `limit`.

> `fields` is an allow list, not a wildcard. There is no way to ask for every field, and `id` is always returned whether or not you name it.

## Idempotency-Key

A unique key you generate per logical operation, 1 to 255 printable ASCII characters. A
UUID is the obvious choice.

Retry with the **same** key and the same body and you get the original response replayed
rather than a second object. This is what makes a network timeout safe: you never know
whether the first request landed, so you retry with the same key and find out.

Same key with a **different** body is `409 IDEMPOTENCY_KEY_CONFLICT`, because reusing a
key for different work is a bug in your client rather than a retry. Same key while the
first request is still running is `409 IDEMPOTENCY_KEY_IN_FLIGHT`; wait and retry.

Keys are scoped to the Site and the endpoint, and are retained for 24 hours. After that
the same key is a new operation.

> Two responses deliberately do **not** replay: the secret from a key creation, and the presigned URL from an upload reservation. Both are live credentials, and persisting one so that a retry could see it again would put it in every database backup taken during the replay window. A replay returns the same object id with the credential field `null` and a `*_replayable: false` flag, so you can tell the difference between a redacted replay and a missing field.

## If-Match and lost updates

The `ETag` from your last read of this object. If it has changed since then you get
`412 PRECONDITION_FAILED` and your write is not applied, so two people editing the same
article cannot silently overwrite each other.

Optional in v1 for compatibility. Omitting it means last write wins. Send it.

**A safe update with If-Match** (returns 200)

```bash

curl -X PATCH https://api.writavo.com/v1/articles/3f1b0c7a-0000-4000-8000-000000000001 \
  -H "Authorization: Bearer wv_sk_EXAMPLE0000000000000000000000000000" \
  -H "Content-Type: application/json" \
  -H "If-Match: W/"1767225600000"" \
  -d '{
  "excerpt": "A practical framework for picking a headless CMS."
}'

```

Send the ETag from your last read. If someone else changed the article you get 412 instead of silently overwriting their work.

- Read the object, keep the `ETag`.
- Send it back as `If-Match` on your update.
- On `412 PRECONDITION_FAILED`, re-read, merge, and retry with the new `ETag`. Nothing was written.

The tag is weak, and deliberately: it versions the row rather than the bytes, so a `fields` projection legitimately returns a different body for the same version.
