API overview
Base URL, authentication, errors, idempotency, pagination and rate limits.
Coding agents: read this page as markdown.
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: the author approves it in a browser and nobody copies a token. You can also sign in with GitHub and create one on Settings → API 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:
curl -H "Authorization: Bearer $RELEASESZN_TOKEN" https://releaseszn.com/api/v1/me
{
"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.
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 wholeAuthorization: Bearer …line, forcurl -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). Neither request needs a token.
-
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:{ "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, …" } -
Show the author
verification_uri_completeanduser_code, and nothing else:device_codeis 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. -
Meanwhile
POST /auth/device/tokenwith{"device_code": "rszd_…"}everyintervalseconds. Until the author decides it answers400 authorization_pending(withRetry-After); sooner than the interval,400 slow_down(add 5 seconds). A denial is403 access_denied, a code past its 10 minutes400 expired_token. Once approved it answers the token, once:{ "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 problem details,
with two extra fields: a stable code and a fix sentence that says what to
do next.
{
"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 lists every change
to the API, and /api/v1/openapi.json always
describes what is running.