API reference · v1.0.0

grailward API

Back up and sync your single-player Diablo II: Resurrected saves through grailward.

The grailward API is the contract the official watcher speaks, and it is public so you can build your own client — a Linux daemon, a Steam Deck script, a NAS cron job.

Getting started

The base URL is https://grailward.com. Every endpoint lives under /api/v1.

You need an API token. Create one on the Devices page of your account — one per machine, named, individually revocable. The raw token is shown exactly once, at creation; grailward stores only its digest and cannot show it to you again. Send it on every request as Authorization: Bearer <api_token>.

This call asks the server what your account currently holds, and is the right way to check that a token works:

curl -H "Authorization: Bearer $GRAILWARD_TOKEN" \
     -H "Accept-Language: en" \
     https://grailward.com/api/v1/sync

A 200 with two arrays means you are in. A 401 means the token is missing, malformed, or revoked.

This document is generated from the OpenAPI spec, which you can fetch and feed to a client generator: /api/v1/openapi.yaml. It is public — no token needed to read it.

What it does

Six data endpoints, two directions:

  • Push a save file (.d2s character, .d2i shared stash) into your account — POST /api/v1/snapshots.
  • Pull — ask what the server currently holds (GET /api/v1/sync), then download the raw bytes of anything that is newer than your local copy.

Map-reveal sidecars (<Name>.map, <Name>.ma0...ma9) ride along on their own pair of endpoints. They are quality-of-life data the game can regenerate, not saves — they are stored last-writer-wins, without history.

Rules a third-party client must know

These are not visible in the payload shapes, and getting them wrong is how you hurt yourself:

  • An upload is never rejected for being "wrong". grailward has no character lock and no conflict gate: whatever you POST is accepted and appended to the account's history. Divergence between two machines is resolved later, by asking the human which save wins — never by refusing bytes at the door. A 422 means the payload was unreadable (bad checksum, corrupt save, unknown stash filename), never that the save was unwanted.
  • Ingestion is idempotent by content. The sha256 of the raw bytes is the identity. Re-send the same save and you get 200 with "status": "unchanged" and no new snapshot — instead of 201 with "status": "created". Retrying a request whose response you lost is always safe. Dedupe looks at the character's whole history, not just the current save.
  • Use download_path verbatim. GET /api/v1/sync hands you a ready-made path for every downloadable thing. Treat it as opaque and concatenate it to the host. Do not build the URL from the character name or the stash mode yourself — the route shape is internal and may change without a version bump; the manifest field will not.
  • Never delete a save to "fix" a sync. Snapshots are append-only server-side; your client should mirror that posture locally.
  • Ask for English. Error messages are localized. Send Accept-Language: en and you get English regardless of the account's website language — an Accept-Language from the client always beats the account preference. The official watcher does this, because machine logs are canonically English. Omit the header and messages come back in the account's language. Never parse the error string: branch on the HTTP status (and, on POST /api/v1/snapshots, on the machine-readable status field).
  • Back off on 503. Uploads and downloads sit behind independent feature flags. When one is off, every affected endpoint answers 503 — the service is fine, that half of the API is paused. Retry later with backoff; do not treat it as a fatal error and do not drop the save.

Authentication

Every data endpoint requires Authorization: Bearer <api_token> — all six of them. The only exception in the whole API is the spec itself, which is public. A missing, malformed, or revoked token is 401.

A token identifies a device, and therefore an account. Everything is scoped to that account: a character that belongs to someone else is 404, not 403 — the API does not confirm the existence of resources you cannot reach. Do not read a 404 as "deleted": read it as "not yours, or not there".

Rate limits

Counted per token (each device has its own budget) in a fixed 10-minute window that opens on your first request. Over the limit is 429 — the response carries no Retry-After; wait out the window. The per-operation budget is listed on each endpoint below. Note that the two sidecar operations share a single budget, since they live in the same resource.

The limiter runs before the request is validated or the resource is resolved, so cheap failures (400, 404) still consume budget. A hot retry loop against a nonexistent character will lock you out just as fast as real traffic.

Errors

Error bodies are {"error": "<human-readable message>"}, with two additions:

  • POST /api/v1/snapshots prefixes its 422 with a machine-readable status (checksum_mismatch, invalid_save, unsupported).
  • PUT /api/v1/characters/{name}/sidecars adds filename to its 422 when the failure can be pinned to one file of the batch.

Sync

What the server currently holds, and how to fetch it.

GET /api/v1/sync #

Manifest of everything the account currently holds

Authorization: Bearer <api_token> 30 requests / 10 minutes · per token

The starting point of every sync cycle. Lists the current save of each character and each shared stash — anything without a current save is simply absent — with its sha256, byte size, the machine that last pushed it, and a download_path ready to use.

The client compares the manifest's sha256 against its local file: same sha, nothing to do; different sha and the server's synced_at is newer, pull it; different sha and the local file is newer, push it.

download_path and sidecars_download_path are opaque. Append them to the host and go. Do not reconstruct them.

This is the tightest budget in the API, because this is the endpoint a polling loop hits. One call per cycle is the intended shape.

Responses

  • 200

    The manifest. An account with nothing in it returns two empty arrays, not an error.

    FieldTypeDescription
    characters array of object

    One entry per character that has a current save, sorted by name. A character whose saves were all archived with set_current: false has no current save and does not appear here.

    name string
    filename string

    The name the save has on disk — <name>.d2s.

    sha256 string

    sha256 of the current save's bytes. This is the whole sync protocol: compare it with the sha256 of your local file. Equal means in sync — do nothing.

    size integer

    Size of the current save in bytes.

    synced_at string (date-time)

    ISO 8601, UTC — when the server received this save. Compare against your local file's mtime to decide who is newer when the shas differ.

    source_machine string | null

    The source_machine the uploading client declared, or null if it declared none.

    download_path string

    Path to the raw save. Use it verbatim — prepend the host, do not rebuild it from the name.

    sidecars array of object

    The character's map-reveal sidecars. Always present; an empty array when there are none. Same sha-comparison protocol as the save itself.

    filename string
    sha256 string

    Lowercase hex sha256.

    size integer
    sidecars_download_path string

    Path to the sidecar batch. Use it verbatim. Always present, even with no sidecars.

    shared_stashes array of object

    One entry per shared stash that has a current save, sorted by (mode, variant).

    mode string softcore · hardcore
    variant string modern · legacy
    filename string

    The name this stash has on disk. Send this exact string back as filename on POST /api/v1/snapshots and the upload lands on this same stash — the round-trip is guaranteed by construction.

    size integer
    sha256 string

    Lowercase hex sha256.

    synced_at string (date-time)
    source_machine string | null
    download_path string

    Path to the raw .d2i. Use it verbatim.

    One character (with map sidecars) and one shared stash

    {
      "characters": [
        {
          "name": "Kristan",
          "filename": "Kristan.d2s",
          "sha256": "4b227777d4dd1fc61c6f884f48641d02b4d121d3fd328cb08b5531fcacdabf8a",
          "size": 8447,
          "synced_at": "2026-07-13T18:04:21Z",
          "source_machine": "windows-pc",
          "download_path": "/api/v1/characters/Kristan/current",
          "sidecars": [
            {
              "filename": "Kristan.ma0",
              "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b",
              "size": 4096
            },
            {
              "filename": "Kristan.map",
              "sha256": "d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35",
              "size": 3
            }
          ],
          "sidecars_download_path": "/api/v1/characters/Kristan/sidecars"
        }
      ],
      "shared_stashes": [
        {
          "mode": "softcore",
          "variant": "modern",
          "filename": "ModernSharedStashSoftCoreV2.d2i",
          "size": 27231,
          "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
          "synced_at": "2026-07-13T18:04:22Z",
          "source_machine": "windows-pc",
          "download_path": "/api/v1/stash/softcore/modern/current"
        }
      ]
    }

    A brand-new account

    {
      "characters": [],
      "shared_stashes": []
    }
  • 401

    The Authorization header is missing, malformed, or carries a token that no longer exists (revoked on the Devices page, or the account was deleted).

    {
      "error": "Invalid or missing token"
    }
  • 429

    Over the rate limit for this token. No Retry-After is sent; wait out the 10-minute window. Failed requests count too — the limiter runs before validation.

    {
      "error": "Too many requests, wait before sending again"
    }
  • 503

    Downloads are paused server-side (feature flag api_download), which covers the manifest as well as the raw-bytes endpoints. Retry later with backoff.

    {
      "error": "Downloads are temporarily disabled — try again later"
    }

Saves

Pushing save files in, pulling raw save bytes out.

POST /api/v1/snapshots #

Upload a save file

Authorization: Bearer <api_token> 60 requests / 10 minutes · per token

Ingests the raw bytes of one save. The content decides where it lands: a .d2s becomes a character snapshot, a .d2i becomes a shared-stash snapshot. The filename never picks the type — but a .d2i carries its mode and variant only in its name, so filename is mandatory for stash uploads (SharedStashSoftCoreV2.d2i, ModernSharedStashHardCoreV2.d2i, …).

Always accepted, never rejected. There is no lock and no conflict check. Two machines pushing the same character is fine: both saves are kept, and the human decides later.

Idempotent. Identity is the sha256 of the raw bytes. A save already in the character's history — current or not — answers 200 / "unchanged" without writing anything. A new one answers 201 / "created".

Request body application/json

FieldTypeDescription
raw_base64 required string (byte)

The save file's raw bytes, base64-encoded. Max 4 MB decoded.

sha256 string

Optional but strongly recommended: the sha256 you computed locally. The server recomputes it and answers 422 / checksum_mismatch if they disagree — that is your only protection against a corrupted upload being stored as if it were a real save.

filename string

Required for shared stashes (.d2i), ignored for characters. A .d2i's bytes do not say whether it is softcore or hardcore, modern or legacy — only the filename does. Send the game's name (SharedStashSoftCoreV2.d2i, ModernSharedStashHardCoreV2.d2i, …), or the one the manifest published for that stash. Uploading a .d2i without it is 422 / invalid_save.

source_machine string

Free-form label for the machine that produced this save ("windows-pc", "steam-deck"). Stored with the snapshot and shown to the user — this is how they tell two machines apart when they have to pick a winner. Optional, but a sync client without it makes a worse story for its user.

set_current boolean

Whether this save becomes the account's current one for that character/stash. Leave it alone (or send true) for a save you read off the local disk — that is the normal case.

Send false when you are archiving a save you just downloaded from grailward: the bytes enter the append-only history, but the current pointer does not move (it already points at them). Uploading a pulled save with set_current: true is harmless but pointless — the sha is already known, so it dedupes to unchanged.

A character save from a Windows box

{
  "raw_base64": "AAAAVwYAAABrAAAA…",
  "sha256": "9f2c1b0e5a7d4c3f8e6b2a1d0c9f8e7d6c5b4a39281706f5e4d3c2b1a0987654",
  "source_machine": "windows-pc"
}

The modern softcore shared stash (filename is required)

{
  "raw_base64": "VlNTSgABAAA…",
  "sha256": "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809",
  "filename": "ModernSharedStashSoftCoreV2.d2i",
  "source_machine": "steam-deck"
}

Archiving a save you just pulled from the server (do not move the pointer)

{
  "raw_base64": "AAAAVwYAAABrAAAA…",
  "sha256": "9f2c1b0e5a7d4c3f8e6b2a1d0c9f8e7d6c5b4a39281706f5e4d3c2b1a0987654",
  "set_current": false
}

Responses

  • 201

    Save stored. status is created.

    Character snapshot result

    FieldTypeDescription
    status string created · unchanged

    created (HTTP 201) — a new snapshot was written. unchanged (HTTP 200) — this exact sha256 was already in the character's history; nothing was written. Both are success.

    character object
    name string
    class string

    The character's class as the parser names it.

    level integer
    hardcore boolean
    snapshot object
    sha256 string

    Lowercase hex sha256.

    created_at string (date-time)

    When the snapshot was stored. On unchanged this is the timestamp of the existing snapshot — the first time these bytes arrived — not of your request.

    item_count integer

    How many items the parser found in the save. A cheap sanity check for your client.

    Shared stash snapshot result

    FieldTypeDescription
    status string created · unchanged
    shared_stash object
    mode string softcore · hardcore
    variant string modern · legacy
    gold integer
    tab_count integer
    item_count integer
    snapshot object
    sha256 string

    Lowercase hex sha256.

    created_at string (date-time)
  • 200

    Save already known (same sha256). status is unchanged, nothing was written, and the returned snapshot is the one already on file. This is the response a retry gets.

    Character snapshot result

    FieldTypeDescription
    status string created · unchanged

    created (HTTP 201) — a new snapshot was written. unchanged (HTTP 200) — this exact sha256 was already in the character's history; nothing was written. Both are success.

    character object
    name string
    class string

    The character's class as the parser names it.

    level integer
    hardcore boolean
    snapshot object
    sha256 string

    Lowercase hex sha256.

    created_at string (date-time)

    When the snapshot was stored. On unchanged this is the timestamp of the existing snapshot — the first time these bytes arrived — not of your request.

    item_count integer

    How many items the parser found in the save. A cheap sanity check for your client.

    Shared stash snapshot result

    FieldTypeDescription
    status string created · unchanged
    shared_stash object
    mode string softcore · hardcore
    variant string modern · legacy
    gold integer
    tab_count integer
    item_count integer
    snapshot object
    sha256 string

    Lowercase hex sha256.

    created_at string (date-time)
  • 400

    raw_base64 missing, or not valid base64.

  • 401

    The Authorization header is missing, malformed, or carries a token that no longer exists (revoked on the Devices page, or the account was deleted).

    {
      "error": "Invalid or missing token"
    }
  • 413

    The decoded save is larger than 4 MB — the ceiling for any single save (real .d2s files are tens of KB; .d2i files stay well under a megabyte). Enforced twice: once on Content-Length before decoding, once on the decoded bytes.

  • 422

    The bytes could not be ingested. This is never a refusal of a valid save — it means the payload is unreadable or unidentifiable. Branch on status, not on the message.

    The sha256 you claimed is not the sha256 of the bytes you sent

    {
      "status": "checksum_mismatch",
      "error": "the provided sha256 does not match the content"
    }

    Corrupt save, truncated file, or a stash upload with no usable filename

    {
      "status": "invalid_save",
      "error": "filename does not indicate the stash mode (expected SharedStashSoftCoreV2.d2i or SharedStashHardCoreV2.d2i)"
    }

    The bytes are not the kind of save the endpoint routed them to

    {
      "status": "unsupported",
      "error": "this is a character save, not a shared stash"
    }
  • 429

    Over the rate limit for this token. No Retry-After is sent; wait out the 10-minute window. Failed requests count too — the limiter runs before validation.

    {
      "error": "Too many requests, wait before sending again"
    }
  • 503

    Uploads are paused server-side (feature flag api_upload). Nothing is wrong with your request and nothing is wrong with the service — this half of the API is temporarily off. Keep the save and retry later with backoff.

    {
      "error": "Uploads are temporarily disabled — try again later"
    }
GET /api/v1/characters/{name}/current #

Download the character's current save

Authorization: Bearer <api_token> 60 requests / 10 minutes · per token

The raw bytes of the character's current .d2s, exactly as the game wrote them — write them straight to disk. The sha256 comes back in the X-Sha256 header so you can verify the transfer without re-reading the file.

Reach this path through the download_path of GET /api/v1/sync, not by building it.

Path parameters

NameTypeDescription
name string

The character's in-game name, exactly as the game spells it (case-sensitive) — the same name the .d2s file carries. This is the only handle a client has on a character; there is no numeric id in the API.

Responses

  • 200

    The raw save file. Write the body to disk byte-for-byte. X-Sha256 lets you verify the transfer, and Content-Disposition carries the filename the game expects.

    HeaderTypeDescription
    X-Sha256 string

    sha256 of the body, lowercase hex — the same value the sync manifest publishes.

    Content-Disposition string

    Always attachment, with the game's filename (e.g. attachment; filename="Kristan.d2s").

  • 401

    The Authorization header is missing, malformed, or carries a token that no longer exists (revoked on the Devices page, or the account was deleted).

    {
      "error": "Invalid or missing token"
    }
  • 404

    No such character in your account, or the character exists but has no current save (it was only ever archived with set_current: false). A character belonging to another account is also 404, deliberately — the API never confirms what you cannot reach.

  • 429

    Over the rate limit for this token. No Retry-After is sent; wait out the 10-minute window. Failed requests count too — the limiter runs before validation.

    {
      "error": "Too many requests, wait before sending again"
    }
  • 503

    Downloads are paused server-side (feature flag api_download), which covers the manifest as well as the raw-bytes endpoints. Retry later with backoff.

    {
      "error": "Downloads are temporarily disabled — try again later"
    }
GET /api/v1/stash/{mode}/{variant}/current #

Download a shared stash's current save

Authorization: Bearer <api_token> 60 requests / 10 minutes · per token

The raw bytes of the current .d2i for one shared stash, addressed by the pair that identifies it: mode (softcore / hardcore) and variant (modern / legacy). The sha256 comes back in the X-Sha256 header, and the Content-Disposition filename is the exact name the game expects on disk — and the exact name to send back in filename on POST /api/v1/snapshots. That round-trip is guaranteed.

Reach this path through the download_path of GET /api/v1/sync, not by building it.

Path parameters

NameTypeDescription
mode string softcore · hardcore

The stash's ladder-independent mode.

variant string modern · legacy

modern is the live file the current game writes (ModernSharedStash…V2.d2i, with the advanced tabs). legacy is the older format the game still keeps on disk; grailward stores it as a downloadable backup.

Responses

  • 200

    The raw save file. Write the body to disk byte-for-byte. X-Sha256 lets you verify the transfer, and Content-Disposition carries the filename the game expects.

    HeaderTypeDescription
    X-Sha256 string

    sha256 of the body, lowercase hex — the same value the sync manifest publishes.

    Content-Disposition string

    Always attachment, with the game's filename (e.g. attachment; filename="Kristan.d2s").

  • 401

    The Authorization header is missing, malformed, or carries a token that no longer exists (revoked on the Devices page, or the account was deleted).

    {
      "error": "Invalid or missing token"
    }
  • 404

    No such stash in your account (that mode × variant was never uploaded), or it has no current save. A stash belonging to another account is 404 too.

  • 429

    Over the rate limit for this token. No Retry-After is sent; wait out the 10-minute window. Failed requests count too — the limiter runs before validation.

    {
      "error": "Too many requests, wait before sending again"
    }
  • 503

    Downloads are paused server-side (feature flag api_download), which covers the manifest as well as the raw-bytes endpoints. Retry later with backoff.

    {
      "error": "Downloads are temporarily disabled — try again later"
    }

Sidecars

Map-reveal files — regenerable, last-writer-wins, not saves.

GET /api/v1/characters/{name}/sidecars #

Download every map-reveal sidecar of a character

Authorization: Bearer <api_token> 60 requests / 10 minutes · per token shares this budget with PUT /api/v1/characters/{name}/sidecars

All of the character's sidecar files in one batch, contents base64-encoded. A character with no sidecars returns {"files": []} — not a 404.

Path parameters

NameTypeDescription
name string

The character's in-game name, exactly as the game spells it (case-sensitive) — the same name the .d2s file carries. This is the only handle a client has on a character; there is no numeric id in the API.

Responses

  • 200

    The character's sidecars, sorted by filename.

    FieldTypeDescription
    files array of object

    Every sidecar of the character, sorted by filename. Empty array when there are none.

    filename string
    sha256 string

    Lowercase hex sha256.

    raw_base64 string (byte)

    The sidecar's raw bytes, base64-encoded — write them to disk as-is.

  • 401

    The Authorization header is missing, malformed, or carries a token that no longer exists (revoked on the Devices page, or the account was deleted).

    {
      "error": "Invalid or missing token"
    }
  • 404

    No character by that name in your account. A character owned by another account answers 404 as well — never 403.

    {
      "error": "Not found"
    }
  • 429

    Over the rate limit for this token. No Retry-After is sent; wait out the 10-minute window. Failed requests count too — the limiter runs before validation.

    {
      "error": "Too many requests, wait before sending again"
    }
  • 503

    Downloads are paused server-side (feature flag api_download), which covers the manifest as well as the raw-bytes endpoints. Retry later with backoff.

    {
      "error": "Downloads are temporarily disabled — try again later"
    }
PUT /api/v1/characters/{name}/sidecars #

Upload map-reveal sidecars (batch upsert)

Authorization: Bearer <api_token> 60 requests / 10 minutes · per token shares this budget with GET /api/v1/characters/{name}/sidecars

Upserts up to 16 sidecar files in one call. Unlike saves, sidecars keep no history: the newest version of each file replaces the previous one (last writer wins). This is safe because the game regenerates them — they are fog-of-war state, not a save.

Per file, the response tells you what happened: created, updated, or unchanged (same sha256 as what is already stored — nothing was written).

The batch is atomic. Every file is validated before anything is persisted: one bad file and the whole request is a 422 with nothing written.

Validation is strict on purpose. filename must match one of the names this character can legitimately have — <Name>.map or <Name>.ma0 through <Name>.ma9, case-sensitive, exactly. Anything else (another character's name, .key, .ctl, a path, a two-digit .ma10) is rejected. sha256 must match the decoded bytes. Each file must be ≤ 1 MB.

Path parameters

NameTypeDescription
name string

The character's in-game name, exactly as the game spells it (case-sensitive) — the same name the .d2s file carries. This is the only handle a client has on a character; there is no numeric id in the API.

Request body application/json

FieldTypeDescription
files required array of object

The batch. May be empty (a no-op that returns 200). Filenames must be unique within the batch — the same name twice is a 422 and nothing is written.

filename required string

Exactly <CharacterName>.map or <CharacterName>.ma0..<CharacterName>.ma9, case-sensitive, matching the character in the path. Anything else is 422.

sha256 required string

sha256 of the decoded bytes. Mismatch is 422 and the batch is dropped.

raw_base64 required string (byte)

The sidecar's raw bytes, base64-encoded. Max 1 MB decoded, per file.

source_machine string

Optional machine label, stored on every file the request writes.

Two sidecars from one machine

{
  "source_machine": "windows-pc",
  "files": [
    {
      "filename": "Kristan.map",
      "sha256": "d4735e3a265e16eee03f59718b9b5d03019c07d8b6c51f90da3a666eec13ab35",
      "raw_base64": "TUFQ"
    },
    {
      "filename": "Kristan.ma0",
      "sha256": "6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4b",
      "raw_base64": "TUEwREFUQQ=="
    }
  ]
}

Responses

  • 200

    The batch was applied — including the empty batch ({"files": []}), which is a no-op. Note this is 200 even when files were created; there is no 201 here.

    FieldTypeDescription
    status string ok

    Always ok — a failed batch is a 422, not a 200 with a bad status.

    files array of object

    One entry per file in the request, in the order you sent them.

    filename string
    status string created · updated · unchanged

    created — first time this file was seen. updated — the content differs and now replaces what was stored (no history is kept). unchanged — same sha256 as what is already stored; nothing was written.

    One new file, one re-sent unchanged

    {
      "status": "ok",
      "files": [
        {
          "filename": "Kristan.map",
          "status": "created"
        },
        {
          "filename": "Kristan.ma0",
          "status": "unchanged"
        }
      ]
    }
  • 401

    The Authorization header is missing, malformed, or carries a token that no longer exists (revoked on the Devices page, or the account was deleted).

    {
      "error": "Invalid or missing token"
    }
  • 404

    No character by that name in your account. A character owned by another account answers 404 as well — never 403.

    {
      "error": "Not found"
    }
  • 413

    The request body is larger than 24 MB (16 files × 1 MB, base64-inflated, plus slack).

  • 422

    A file in the batch failed validation, so none of them were written. filename names the offender when the failure can be pinned to one file; it is absent when the batch itself is malformed (not an array, more than 16 files, the same filename twice).

    A name this character cannot have

    {
      "error": "invalid sidecar filename",
      "filename": "Outro.map"
    }

    The declared sha256 does not match the decoded bytes

    {
      "error": "the provided sha256 does not match the content",
      "filename": "Kristan.map"
    }

    More than 16 files in one request

    {
      "error": "too many sidecars in one request (max 16)"
    }
  • 429

    Over the rate limit for this token. No Retry-After is sent; wait out the 10-minute window. Failed requests count too — the limiter runs before validation.

    {
      "error": "Too many requests, wait before sending again"
    }
  • 503

    Uploads are paused server-side (feature flag api_upload). Nothing is wrong with your request and nothing is wrong with the service — this half of the API is temporarily off. Keep the save and retry later with backoff.

    {
      "error": "Uploads are temporarily disabled — try again later"
    }

Reference

The machine-readable contract itself.

GET /api/v1/openapi.yaml #

This document, machine-readable

Public · no token

The OpenAPI 3.1 spec this page is rendered from. Feed it to a client generator.

Public — no token needed. It is the one endpoint in the API that takes no Authorization header, so fetching it is never gated on having an account. It is also the one that must stay reachable from an automated User-Agent, which is why it lives under /api/ like everything else instead of next to the human page.

Cached for an hour (Cache-Control: public, max-age=3600); an ETag lets you revalidate for free. Do not pin a copy forever — the spec is how you learn a field changed.

Responses

  • 200

    The spec.

  • 304

    Not modified — your If-None-Match matched the current ETag.