# API overview

Base URL, authentication, errors, idempotency, pagination and rate limits.

The releaseszn API is a REST API at `https://releaseszn.com/api/v1` for
publishing projects and releases. It accepts and returns JSON, and it's
designed for coding agents: every error explains how to fix it, and every
`POST` is safe to retry.

## Authentication

Requests that change data need an API token. An agent gets one by
[device sign-in](#device-sign-in): the author approves it in a browser and
nobody copies a token. You can also [sign in](/login) with GitHub and
create one on [Settings → API tokens](/settings/tokens). Send it on every
request:

| Header            | Value                          | Required                       |
| ----------------- | ------------------------------ | ------------------------------ |
| `Authorization`   | `Bearer rsz_…`, your API token | For `submit` and `read` scopes |
| `Content-Type`    | `application/json`             | For requests with a body       |
| `Idempotency-Key` | A unique value, such as a UUID | Recommended on every `POST`    |

Tokens have scopes:

| Scope    | Allows                                             |
| -------- | -------------------------------------------------- |
| `read`   | Reading your own projects, including drafts        |
| `submit` | Creating and updating projects, releases and media |

No scope allows voting, following or saving. Every token expires: after
30 days or 90 (the default), as you choose. `GET /me` says when; make a
new one on `/settings/tokens` before then. You can have up to 20 at a
time.

To check a token, call `GET /me`, which works with any scope:

```bash
curl -H "Authorization: Bearer $RELEASESZN_TOKEN" https://releaseszn.com/api/v1/me
```

```json
{
  "user": { "handle": "alice", "display_name": "Alice" },
  "token": {
    "name": "laptop agent",
    "prefix": "rsz_ab12",
    "scopes": ["submit"],
    "expires_at": "2027-01-07T12:00:00Z"
  },
  "github": {
    "status": "ok",
    "renew_url": "https://releaseszn.com/auth/github",
    "next": "Nothing to do: GitHub accepts the author's sign-in."
  }
}
```

A missing token gets `401 token_missing`; an unknown, revoked or expired one
gets `401 token_invalid`.

`github` is your GitHub sign-in on the website, which `POST /projects`
uses to check that you may add a repository; `GET /me` asks GitHub about
it at most once a minute (a new sign-in is checked at once), so polling it
doesn't spend your GitHub quota. `status` is `ok`, `renew_required` (no sign-in stored, or
GitHub no longer accepts it: `POST /projects` would answer
`403 github_reauth_required`) or `unknown` (GitHub didn't answer within
three seconds). To renew it, open `renew_url` in a browser and sign in
with GitHub, even if you are already signed in; a new API token doesn't
change it.

> [!WARNING]
> A token is shown only once, when you create it. Save it in a file only
> you can read, such as `~/.config/releaseszn/auth-header` (the whole
> `Authorization: Bearer …` line, for `curl -H @file`), never in a chat, and
> revoke it on your settings page if it leaks.

### Device sign-in

How an agent without a token gets one, modelled on OAuth 2.0 device
authorization ([RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)). Neither
request needs a token.

1. `POST /auth/device`, optionally with `{"client_name": "Claude Code in ripfast"}`
   (1–60 characters, shown to the author and given to the token as its
   name). The answer:

   ```json
   {
     "device_code": "rszd_…",
     "user_code": "BCDF-GHJK",
     "verification_uri": "https://releaseszn.com/device",
     "verification_uri_complete": "https://releaseszn.com/device?code=BCDF-GHJK",
     "expires_in": 600,
     "interval": 5,
     "next": "Show the author this link and code, …"
   }
   ```

2. Show the author `verification_uri_complete` and `user_code`, and
   nothing else: `device_code` is a secret. They open the link, sign in
   with GitHub, check the program's name, the time and the address it
   asked from, and approve or deny.

3. Meanwhile `POST /auth/device/token` with `{"device_code": "rszd_…"}`
   every `interval` seconds. Until the author decides it answers
   `400 authorization_pending` (with `Retry-After`); sooner than the
   interval, `400 slow_down` (add 5 seconds). A denial is
   `403 access_denied`, a code past its 10 minutes `400 expired_token`.
   Once approved it answers the token, once:

   ```json
   {
     "token": "rsz_…",
     "token_type": "Bearer",
     "name": "Claude Code in ripfast",
     "scopes": ["submit"],
     "expires_at": "2027-01-16T12:00:00Z",
     "handle": "alice",
     "next": "Save the token without printing it …"
   }
   ```

   Save it straight to a file in the command that receives it, so it never
   appears in a terminal or a chat. Polling again answers
   `400 invalid_device_code`.

The token belongs to whoever approved, has the `submit` scope, lasts 90
days, and is listed (and revocable) on `/settings/tokens` under the
client's name. It can do everything `submit` allows except delete the
account: `DELETE /me` with it is `403 device_token_cannot_delete_account`,
and the author deletes their account on `/settings` themselves. An address may start 10 sign-ins an hour, and an IPv6 /48 30.

## Errors

Errors use [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details,
with two extra fields: a stable `code` and a `fix` sentence that says what to
do next.

```json
{
  "type": "https://releaseszn.com/docs/errors#hero_required",
  "title": "Release needs a hero screenshot",
  "status": 422,
  "code": "hero_required",
  "detail": "Release v1.2.0 has no hero image.",
  "fix": "Upload a screenshot with role \"hero\" and release_tag \"v1.2.0\".",
  "errors": [{ "field": "media", "code": "hero_required" }]
}
```

| Status | Meaning                                                        |
| ------ | -------------------------------------------------------------- |
| `400`  | The request is malformed                                       |
| `401`  | The token is missing, invalid or revoked                       |
| `403`  | The token lacks the scope, or you can't manage this repository |
| `404`  | The project, release or media doesn't exist                    |
| `409`  | The request conflicts with the current state                   |
| `422`  | The request is valid but a rule failed; read `code` and `fix`  |
| `429`  | Rate limited; wait for the `Retry-After` header                |

## Idempotency

Send an `Idempotency-Key` header with every `POST`. If you retry with the same
key within 24 hours, you get the stored response instead of a duplicate
(`Idempotent-Replayed: true`). Using the same key with a different body
returns `422 idempotency_key_reused`.

Only successes are stored. A refusal (`4xx`) changed nothing, so a retry
with the same key runs again: fix what the error says and send the same
request. If the first request is still running when the retry arrives, the
retry gets `409 request_in_progress`; wait a second and send it again.
Device sign-in (`POST /auth/device`, `POST /auth/device/token`) is never
stored: its answers hold secrets that are handed out once.

Creating a project that already exists returns the existing project, so
retrying `POST /projects` is always safe.

## Pagination

The list endpoints (`GET /projects?mine=true` and
`GET /projects/{slug}/releases`) return 20 items per page, newest first.

| Name          | Location        | Description                                          |
| ------------- | --------------- | ---------------------------------------------------- |
| `limit`       | Query parameter | Items per page: 1 to 100, 20 when left out           |
| `cursor`      | Query parameter | Opaque cursor from a previous response               |
| `next_cursor` | Response field  | Cursor for the next page, or `null` on the last page |

Follow `next_cursor` until it is `null`; a list that fits on one page has
`"next_cursor": null`. To find a project's drafts, filter instead of
paging: `GET /projects/{slug}/releases?status=draft`. A `limit` outside
1–100 or a cursor that isn't one of ours returns `400 invalid_query`.

## Rate limits

| Limit                                              | Value         |
| -------------------------------------------------- | ------------- |
| Requests per token                                 | 60 per minute |
| Requests without a valid token, per address        | 60 per minute |
| Device sign-ins started, per address               | 10 per hour   |
| Device sign-ins started, per IPv6 /48              | 30 per hour   |
| New projects, accounts under 30 days old on GitHub | 3 per day     |

Every response to a request with a token carries `X-RateLimit-Limit`,
`X-RateLimit-Remaining` and `X-RateLimit-Reset` (seconds until a request
frees up), and `GET /me` includes the same numbers as `rate_limit`.
Requests over a limit get `429` with a `Retry-After` header in seconds:
wait that long and retry with the same `Idempotency-Key`. Requests
without a token, and those whose token is wrong, expired or revoked,
share one budget per address, so a script that keeps sending a bad token
gets `429` instead of `401` after 60 tries.

## Asynchronous work

Imports and media processing run in the background. Resources expose a
status field to poll: `import_status` on projects and `status` on media.

## Versioning

The version is part of the URL. Breaking changes (a field removed or
renamed, a type or a status code changed, a new required input) ship as
`/api/v2`, and `/api/v1` keeps answering beside it. Additions happen in
place: new endpoints, new optional inputs, new response fields, new error
codes. So ignore fields you don't recognize, and treat an unknown error
`code` by its HTTP status. The [changelog](/changelog) lists every change
to the API, and [`/api/v1/openapi.json`](/api/v1/openapi.json) always
describes what is running.

## Next steps

<div class="doc-cards">
<a class="doc-card" href="/docs/api-endpoints"><strong>Endpoints</strong><span>Projects, releases, media and the public read API.</span></a>
<a class="doc-card" href="/docs/quickstart"><strong>Quickstart</strong><span>Let your agent make these calls for you.</span></a>
</div>
