---
title: Conventions and errors
description: The base URL, bearer authentication and key scopes, identifiers, request and response shapes, status codes, the ceilings and rate limits, and idempotent pushes.
order: 1
---

# Conventions and errors

## Base URL

The API lives at:

```
https://api.docsary.com/v1
```

On a docs hostname, `/v1` is not the API: that path belongs to the docs site, which is why `v1` is a [reserved page path](/writing#reserved-paths).

## Authentication

Every endpoint except `/__health` and the endpoint index takes a bearer token:

```
Authorization: Bearer dsy_XXXX
```

Organization API keys start with `dsy_` followed by 64 hexadecimal characters. A key sees only its own organization's sites — the organization is taken from the token, never from the request, so there is no cross-tenant path to guard.

Keys are provisioned for you; see [Access](/access).

**Scopes.** A key is write-capable or read-only. A read-only key may issue `GET` requests and nothing else; a mutation with one returns:

```json
{ "error": "this API key is read-only (scopes: 'read'); a write-capable scope is required" }
```

with `403`. Ask for a read-only key for anything that only needs to look — a status check, a docs linter in CI, a dashboard.

**Handling.** Keys are stored only as a hash. A key is displayed once, when it is issued, and cannot be recovered afterwards. Treat one as deploy credentials: it can rewrite every page on every site in your organization. Ask for a separate named key per consumer so revoking one does not take down the others, and keep keys out of your repository. `GET /v1/whoami` reports which key you are holding, its scopes, and your plan. Revocation is immediate — a revoked key returns `401` on its next request.

## Identifiers

Every record has a prefixed identifier that says what it is:

| Prefix | Record |
| --- | --- |
| `org_` | organization |
| `site_` | site |
| `ver_` | version |
| `pg_` | page |
| `ast_` | asset |
| `dom_` | domain |
| `rd_` | redirect |
| `rep_` | report |

Where a path takes `:site` or `:ver`, **either the slug or the id is accepted**. `/v1/sites/acme/versions/1.0` and `/v1/sites/site_46269.../versions/ver_34c2a...` address the same thing. Slugs are readable and stable in practice; ids are stable by construction and survive a rename.

Pages are addressed by path, not by id.

## Requests

Bodies are JSON with `content-type: application/json`, with one exception: asset uploads send the file as a raw body and use the `content-type` header to declare the file's own type.

`PATCH` bodies are sparse — send only the fields you are changing. One exception matters: `settings` on a site is a **whole-object replace**, not a merge. Read the current object, modify it, and send it back complete.

Unknown fields in a request body are ignored.

## Responses

Success responses are JSON objects. There is no envelope: a list endpoint returns `{"sites": [...]}`, not `{"data": {...}}`.

Errors are a single-key object:

```json
{ "error": "site not found" }
```

Some errors carry extra fields alongside `error`: attaching an already-attached hostname adds `to_this_site`, and a limit refusal adds `quota`, `limit`, and `plan`.

## Status codes

| Code | Meaning |
| --- | --- |
| `200` | Success |
| `201` | Created — a new site, version, page, asset, or domain |
| `400` | Malformed request: invalid slug, invalid path, unsafe URL or colour, unknown query parameter value, malformed percent-encoding, missing required field |
| `401` | Missing or invalid bearer token |
| `403` | Read-only key on a write, or a [ceiling](#limits) reached |
| `404` | No such site, version, page, asset, or domain |
| `409` | Conflict: slug already exists, version slug would shadow existing page URLs, hostname already attached, domain not yet verified |
| `413` | Asset over the 20 MB per-file cap, over your organization's storage ceiling, or an import request over the [per-request cap](#request-size) |
| `429` | A [rate limit](#rate-limits): writes per minute, failed authentications per minute, or the hourly [report](/api/reports#filing-limit) filing limit. Carries `retry-after` |
| `502` | An external lookup failed — currently only domain verification |
| `503` | Asset storage is temporarily unavailable |

A `404` on a nested route can mean the parent is missing. `/v1/sites/acme/versions/1.0/pages` returns `{"error":"site not found"}` when the site is wrong and `{"error":"version not found"}` when the site is right and the version is not. The message distinguishes them.

## Limits

Docsary is free, and every organization runs under a set of ceilings on how many sites it may run, how many versions each site may hold, how many pages a version may hold, how many redirects a site may carry, how many custom domains it may attach, and how much asset storage it may use. They exist so one runaway script cannot exhaust shared capacity — there is no larger tier on the other side of them. The numbers are in [Access](/access#the-ceilings). The `docsary.com` address every site is given at creation is not one of them: it arrives with the site and counts against nothing.

A call that would cross a ceiling is refused rather than partially applied. The response body names it precisely: `error` states it in words, `quota` says which limit was hit — `sites`, `versions_per_site`, `pages_per_version`, `redirects_per_site`, `custom_domains`, or `asset_bytes` — `limit` gives the number that applied, and `plan` names your plan. Nothing has to be inferred from a bare `403`.

Most refusals return `403`; the storage one returns `413`. A bulk import that runs out of room mid-chunk keeps and reports the pages it did write, in a `diff` field alongside the error, so you can see exactly where it stopped. `GET /v1/whoami` reports which plan you are on.

No capability is gated on the plan. Every endpoint in this reference works on every organization; only the counts differ.

## Rate limits

Three limits are about rate rather than count. Each answers `429` with a `retry-after` header in seconds, and a body carrying `limit`, `window`, and `retry_after`:

```json
{
  "error": "too many write requests: this organization may make 240 writes per minute. Slow the push down and retry; nothing was written.",
  "limit": 240,
  "window": "minute",
  "retry_after": 43
}
```

| What | Ceiling | Counted per |
| --- | --- | --- |
| Write requests — anything that is not `GET`, `HEAD` or `OPTIONS` | 240 per minute | organization |
| Failed authentications | 20 per minute | calling address |
| Reports filed | 20 per hour | organization |

**Reads are never rate limited.** A `GET` costs one lookup and is cacheable, so there is no ceiling on reading your own content however often you do it.

**A `429` means nothing was written.** The refusal happens before the request reaches the handler, so retrying after `retry_after` seconds is safe and is never a duplicate.

The write ceiling is set well above a real push: an import runs at roughly six files per request, so a two-thousand-page manual is a few hundred requests and finishes inside two windows without pausing. If you are hitting it, the cause is usually a retry loop rather than a large site.

The authentication ceiling counts **failures**, not requests: a working key is never throttled by it, however hard you push. Twenty rejected tokens in a minute from one address earns a `429` until the minute is out. Two things follow from that. Retrying a `401` in a tight loop makes things worse rather than better — fix the key. And because the count is per calling address, a large office behind one address can be locked out by one colleague's broken script for the remainder of the minute; the window is short by design for exactly that reason.

## Request size

**Send about six files per import request.** Keep chunks small. Chunking is safe: import is additive, so any number of chunks may be posted before a final prune call establishes the complete page set.

One import request may carry at most **100 files** and **2 MB of markdown**. Over either, the call is refused with `413` and nothing is written.

## Idempotency

Page writes are content-addressed. Each page is keyed by a hash of its markdown source; an import whose hashes match what is stored reports those paths as `unchanged` and writes nothing.

Pushing the same folder twice is therefore free, and pushing a folder in which one file changed costs one page's work. Retrying a failed or partially-applied push is safe.

## Publishing

Content is live as soon as the write returns. There is no build step, no publish gate, no purge call, and no propagation delay to wait out.

Served pages carry two publish-confirmation headers: `x-docsary-epoch`, whose value changes whenever the site is written to, and `x-docsary-cache`, reading `hit` or `miss`. Together they let a deployment script confirm that a push actually reached the reader-facing site.
