# MakerRun Library API — v1

A stable HTTP surface over the design library: browse it, publish to it, upload the files and photos,
and mark a listing free or for sale.

**Base URL** `https://makerrun.com/api/v1`

> **Moved 2026-08-21.** This API belonged to `bedready.io` until the project split in two: the
> converter is now open-source at `bedready.io`, and this library is **MakerRun**. The old base,
> `https://bedready.io/api/v1`, still answers, and did not stop answering when the converter moved
> to its own deployment on 2026-08-21: `bedready.io/api/*` is **rewritten** to `makerrun.com/api/*`
> rather than redirected, so method, body and `Authorization` all survive the hop. Treat it as a
> compatibility alias and use `makerrun.com`.

**This document** [docs.makerrun.com](https://docs.makerrun.com/) — raw markdown at
[docs.makerrun.com/api.md](https://docs.makerrun.com/api.md), which is this file byte-for-byte, so
`curl … | diff` against your vendored copy answers "what changed". The page is rendered from
`docs/API.md` in the MakerRun repository at build time; the two cannot disagree.

> **The docs host moved 2026-08-21**, from `docs.bedready.io` to `docs.makerrun.com`. It had been
> left on the converter's domain after the split, which the deployment split then made plainly
> wrong: `bedready.io` is now served from a different repository entirely, so the old name was the
> one host still answering for the library from an address belonging to the converter.
>
> **`docs.bedready.io` now 301s here and will keep doing so.** Nothing you have vendored breaks. If
> you pin the raw-markdown URL in a diff check, repoint it at `docs.makerrun.com/api.md` so you are
> reading the canonical address rather than following a redirect.

---

## Contents

- [Why this exists instead of talking to Supabase](#why-this-exists-instead-of-talking-to-supabase)
- [Authentication](#authentication)
  - [Desktop apps: linking, and staying signed in](#desktop-apps-linking-and-staying-signed-in) — `/app-link`, `POST /api/app-token`
- [Two-factor authentication](#two-factor-authentication)
- [Publishing: the whole sequence](#publishing-the-whole-sequence)
- [Selling: what this API does and does not do](#selling-what-this-api-does-and-does-not-do)
- [Endpoints](#endpoints)
- [Objects](#objects)
- [Errors](#errors)
- [Limits](#limits)
- [Things that will bite you](#things-that-will-bite-you)

---

## Why this exists instead of talking to Supabase

Supabase already exposes every table over HTTP, and a client could use it directly. That would tie
the client to this database's **column names**. When `verified` was split into two independent
signals, an app reading the table would have broken silently; an app reading this API would not.

These endpoints return a **stable shape that is deliberately not the table shape**. Columns change
underneath. This does not, without a `v2`.

## Authentication

```http
Authorization: Bearer <supabase access token>
```

The same token a signed-in browser session holds. Sign the user in with Supabase, pass the token
through.

Writes execute **as that user**, so row-level security applies exactly as it does on the website: the
API can never do more than the person could do themselves. **There is no API key that bypasses this,
on purpose** — a second set of rules is a second set to keep in step with the first.

Public reads need no token. Tokens expire; treat `401` as "re-authenticate", not as "forbidden".

### Desktop apps: linking, and staying signed in

A desktop app cannot hold the Supabase URL and anon key — shipping them in a binary publishes them.
So the app never talks to Supabase auth directly. It gets a refresh token once, over a deep link, and
exchanges it here from then on.

**The handshake, in order:**

1. **The app mints a random nonce** and opens the system browser at
   `https://makerrun.com/app-link?state=<nonce>`. Not an embedded webview — the user needs to see a
   real address bar to trust what they are signing into, and an existing browser session means they
   usually do not have to sign in again at all.
2. **The user signs in if needed, and presses Connect.** The page hands back:
   ```
   bedready://auth#access_token=…&refresh_token=…&expires_at=…&state=<nonce>
   ```
3. **The app MUST validate `state` against the nonce it minted, and reject the callback if it does
   not match.** Without that check, any page on the machine can fire a `bedready://` URL and the app
   will consume whatever tokens it carries. `state` is omitted by very old builds; treat *absent* as
   a failed handshake rather than as permission.
4. **The app stores the refresh token** and refreshes from then on.

> **The tokens ride in the URL *fragment*, and that is load-bearing.** Everything after `#` is never
> transmitted to any server — not to bedready.io, not to a proxy, not into an access log. Moving these
> to a query string would put refresh tokens in server logs. If you are re-implementing this flow, keep
> the fragment.
>
> A **copy-code fallback** exists on the same page for machines where the deep link does not register.
> It carries the same values and is the same secret — treat a pasted code exactly like a callback,
> including the `state` check where present.

#### `POST /api/app-token`

Exchanges a refresh token for a fresh access token. **Not under `/v1`** — it is auth transport, not
library API, and it is unauthenticated by nature (the refresh token *is* the credential).

```json
POST /api/app-token
{ "refresh_token": "…" }
```

```json
200 OK
{ "access_token": "…", "refresh_token": "…", "expires_at": 1786531200 }
```

> **⚠️ The refresh token may be rotated, and the app must store the one it gets back.** Supabase can
> return a *different* refresh token from the one you sent. Keep using the old one and the app will
> sign itself out at some later, unrelated moment — the kind of bug that looks like a server problem
> and is not.

`expires_at` is a **Unix timestamp in seconds**, and may be `null`. Refresh before it, not after a
401 — the endpoint is rate-limited and a 401-driven retry storm will hit that ceiling.

| Status | Meaning | What the app should do |
|---|---|---|
| `400` | No `refresh_token` in the body | Fix the call; not retryable |
| `401` | Token invalid, expired or already rotated away | **Re-link** — send the user through `/app-link` again. Do not retry. |
| `429` | Rate limited — **20 per 5 minutes per IP** | Honour `Retry-After`. A real client refreshes about hourly; hitting this means a refresh loop. |
| `503` | Backend unavailable | Retry with backoff |

Errors here return `{ "error": "…" }` with short codes (`rate`, `invalid`, `server`), **not** the
`{ error: { code, message } }` envelope the `/v1` endpoints use. Do not share one parser between them.

## Two-factor authentication

If an account has a **verified** second factor, a token from a session that never presented it is
**refused** — on every authenticated endpoint, reads included:

```json
{ "error": { "code": "mfa_required", "message": "This account has two-factor authentication enabled, and this session has not completed it. …" } }
```
`403`

Enrolment alone is not protection: Supabase will let a factor be enrolled and then let an old session
carry on. So the check is on the token's assurance level, not on a settings flag.

| account | token | result |
|---|---|---|
| no verified factor | anything | allowed — 2FA is opt-in |
| verified factor | `aal1` | **403 `mfa_required`** |
| verified factor | `aal2` | allowed |

A client should complete Supabase's MFA challenge and retry with the resulting token. `GET /me`
reports `twoFactor: { enabled, satisfiedByThisSession }` — "on" and "on and proved" are different
states, and an account screen needs both.

An **unverified** enrolment never blocks anything; an abandoned setup is not a requirement.

## Publishing: the whole sequence

A listing is built in three calls, because a design is a row, a model file and photographs, and any
of the three can fail on its own.

```
1.  POST /designs                     → { design: { slug } }, status "pending"
2.  POST /designs/{slug}/files        → stores the model, runs verification
3.  POST /designs/{slug}/images       → stores photos, strips metadata, sets the cover
```

**Nothing here publishes.** `status` starts at `pending` and BedReady decides when it goes live —
`status` cannot be set through the API at all. Poll `GET /me` to see where a listing stands.

Step 2 also **verifies**: if the file is a `.3mf` carrying a real slicer profile, the server records
the check and which printer the profile names. A file with no profile still uploads and still fails
verification, with the reason returned.

## Selling: what this API does and does not do

A listing is **free**, or **for sale via the creator's own payment link**.

**BedReady never takes the payment.** It does not receive, hold, or distribute money, and it has no
record that a sale happened — see [Terms §6](https://makerrun.com/terms). `sale.platform` is always
`"external"` to make that explicit in every payload. A client rendering a Buy button must send the
buyer to `sale.url`.

Payment links are restricted to an allowlist of known payment hosts (`buy.stripe.com`,
`*.gumroad.com`, `*.lemonsqueezy.com`, `payhip.com`). Anything else is rejected with a `422`.

`sale.provider` is one of `stripe`, `gumroad`, `lemonsqueezy`, `payhip`, or `null`. **Treat it as an
open set** — Payhip was added on 2026-08-09 and the list will grow as providers that pay creators
outside Stripe's 48 countries are verified. A client that switches exhaustively on it should have a
default branch rather than assume four.

Payhip is apex-only (`payhip.com/b/<key>`, `payhip.com/buy?link=<key>`, `payhip.com/<seller>`): it
issues no per-seller subdomain, and a seller's Payhip custom domain is not accepted, because an
arbitrary host behind a Buy button is the phishing case the allowlist exists to prevent.

---

## Endpoints

### `GET /designs`

Published designs. Public, no token.

| query | meaning |
|---|---|
| `q` | search title and description |
| `category`, `material` | exact filters (`material`: `rigid` · `flexible` · `multi`) |
| `forSale` | `true` / `false` |
| `verified` | `true` — only listings whose profile carries the badge |
| `limit`, `offset` | paging; `limit` caps at 100, defaults to 25 |

```json
{
  "designs": [ "…DesignDTO…" ],
  "page": { "limit": 25, "offset": 0, "total": 37, "returned": 25 }
}
```

`page.total` always counts the same set the rows came from, `verified=true` included — the filter is
applied before paging, so pages are full and the total describes what you are paging through.

### `GET /designs/{slug}`

One design plus its files, images and print profiles. Public.

```json
{
  "design": "…DesignDTO…",
  "files":  [ { "filename": "part.3mf", "sizeBytes": 812344, "hosted": true } ],
  "images": [ { "url": "https://…", "kind": "cover", "printConfirmed": false } ],
  "profiles": [
    { "printer": "u1", "printerBrand": "Prusa", "printerModel": "MK4S",
      "filamentType": "PLA", "colorCount": 4, "settings": { "…": "…" },
      "badge": true, "fileChecked": true, "printPhotoConfirmed": false }
  ]
}
```

Files report `hosted` and never a storage path — downloads go through the website so they are counted
and the licence is shown.

### `POST /designs`

Create a listing. Requires a token. Returns `201`.

```json
{ "title": "Desk hook", "description": "…", "category": "household",
  "material": "rigid", "license": "CC-BY", "creator": null, "nsfw": false,
  "saleUrl": "https://buy.stripe.com/…", "salePrice": 15, "saleCurrency": "SAR",
  "saleKind": "both", "saleShipsFrom": "Riyadh", "saleLeadTimeDays": 3 }
```

Only `title` is required. Sale fields are all optional and may be omitted entirely for a free listing.

### `PATCH /designs/{slug}`

Update your own listing.

**Absent means "leave alone". `null` means "clear".** Clearing `saleUrl` un-lists the design and
clears the price with it, so a listing can never show a price with nowhere to pay.

### `DELETE /designs/{slug}`

Remove your own listing, its rows and its stored files. Returns `{ "deleted": true, "slug": "…" }`.

### `POST /designs/{slug}/files`

`multipart/form-data`, field **`file`**. Stores the model **and verifies it**.

```bash
curl -X POST https://makerrun.com/api/v1/designs/desk-hook-a1b2c3/files \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@desk-hook.3mf"
```

```json
{
  "file": { "filename": "desk-hook.3mf", "sizeBytes": 812344, "url": "https://…" },
  "verification": { "verified": true, "printer": "MK4S", "brand": "Prusa", "reason": null },
  "status": "pending"
}
```

`verified: false` comes with a `reason` — usually *"no slicer profile in the file — it is geometry,
not a print-ready export"*. The upload still succeeded; only the check failed.

Accepts `.3mf`, `.stl`, `.obj`, `.step`, `.stp`. Only a `.3mf` can carry a profile, so only a `.3mf`
can verify.

Rejected with `409` if the listing links to a file hosted elsewhere — clear `sourceUrl` first.

### `POST /designs/{slug}/images`

`multipart/form-data`, one or more **`images`** fields, optional **`kind`** (`gallery` | `print`).

```bash
curl -X POST https://makerrun.com/api/v1/designs/desk-hook-a1b2c3/images \
  -H "Authorization: Bearer $TOKEN" \
  -F "images=@front.jpg" -F "images=@back.jpg" -F "kind=print"
```

```json
{
  "images": [ { "url": "https://…", "filename": "front.jpg",
                "strippedMetadata": true, "aiGenerated": false } ],
  "coverSet": "https://…",
  "failures": []
}
```

**Every image has its metadata removed server-side.** You cannot opt out. Camera, timestamp and GPS
are stripped before storage; the pixels, colour profile and orientation are preserved exactly
(nothing is re-encoded). `strippedMetadata` reports whether anything was actually removed.

**Resize before you upload: 2048px on the longest edge.** An image above that is rejected — it
appears in `failures` with its actual dimensions, and the rest of the request still succeeds. This is
the other side of the promise above: because nothing is re-encoded, what you send is what every
visitor downloads forever, so the ceiling has to be yours to apply. 2048 covers the largest slot on
the site at 2× device pixel ratio; cards request 640×480. JPEG, PNG and WebP are measured. Portrait
and landscape are the same rule — it is the longest edge, whichever that is.

**AI provenance is read before stripping.** If an image's own Content Credentials say a model made
it, `aiGenerated` is `true`, and if that image becomes the cover the listing is labelled accordingly.
This is read from the file, never from a client field — there is no way to declare or suppress it.

`kind=print` records a **claim**. A moderator still has to confirm it before it earns the 📷 Real
print tag or the ranking boost. The response says so.

The first image on a listing with no cover becomes the cover.

### `GET /me`

The token's owner and their listings, at every status.

```json
{
  "user": { "id": "…", "displayName": "Turki", "avatarUrl": "https://…", "trusted": false },
  "designs": [ { "…DesignDTO…": "…", "status": "pending" } ]
}
```

`trusted` means a verified maker, whose new listings can auto-publish. It is not a badge.

### `GET /me/activity`

Everything that has happened to the caller's listings, plus per-listing stats. This is what the
creator dashboard renders.

| query | meaning |
|---|---|
| `limit` | timeline length; caps at 200, defaults to 25 |

```json
{
  "designs": [
    { "slug": "desk-hook-a1b2c3", "title": "Desk hook", "status": "published",
      "stats": { "downloads": 12, "likes": 3, "saves": 1, "comments": 0,
                 "makes": 1, "photos": 4, "vaultRequestsPending": 0 } }
  ],
  "activity": [
    { "kind": "make", "at": "2026-08-08T…", "designSlug": "desk-hook-a1b2c3",
      "designTitle": "Desk hook", "actorId": "…", "summary": "posted a make of your design" }
  ],
  "totals": { "designs": 4, "downloads": 12, "pendingVaultRequests": 0 },
  "downloadsNote": "Downloads are a running total, not a history: …"
}
```

`kind` is one of `like` · `save` · `comment` · `make` · `vault_request` · `follow`.

**Downloads are not in the timeline and cannot be.** `download_count` is a counter — no row is
written per download, so there is a total and no history. It appears under `stats` and `totals`, and
`downloadsNote` explains why it is missing from `activity`. **Render that note.** A creator reading a
timeline that silently omits downloads concludes there were none.

The timeline is merged across all sources and **then** limited, so the newest events survive
regardless of which source they came from.

### `GET /me/analytics`

Is it growing, as a shape. The sibling of `/me/activity`, which answers *what happened* as a list.
Requires auth; MFA is enforced if the caller has it enrolled.

| Query | | |
|---|---|---|
| `days` | `7`, `30` or `90` | Default `30`. **Anything else is silently coerced to 30** — it is not a 400. |

```json
{
  "days": 30,
  "designs": [
    {
      "slug": "nfc-filament-tags",
      "title": "NFC Filament Tags",
      "status": "published",
      "series": {
        "downloads": [ { "day": "2026-07-13", "count": 0 }, { "day": "2026-07-14", "count": 2 } ],
        "fileServed": [], "likes": [], "saves": [], "comments": [], "makes": []
      },
      "totals": { "downloads": 2, "fileServed": 5, "likes": 0, "saves": 1, "comments": 0, "makes": 0 },
      "downloads": {
        "window": 2,
        "allTime": 4,
        "previousWindow": 0,
        "changePercent": null,
        "describe": "new this period"
      }
    }
  ],
  "totals": { "downloads": 2, "fileServed": 5, "likes": 0, "saves": 1, "comments": 0, "makes": 0 },
  "note": "…"
}
```

**Every series is dense and ascending**, exactly `days` entries long, ending today (UTC). Days with
no events are present with `count: 0` rather than omitted — so you can plot it without filling gaps,
and `series.downloads.length === days` always. Metric keys are fixed: `downloads`, `fileServed`,
`likes`, `saves`, `comments`, `makes`.

`changePercent` is **`null`, not `0`, when the previous window was empty** — you cannot compute a
percentage change from zero, and rendering "0%" there would claim flatness where there is no baseline.
Use `describe`, which resolves that honestly: `"nothing yet"`, `"new this period"`, `"unchanged"`, or
a worded change.

> **⚠️ Downloads and everything else do not have the same history, and the endpoint says so rather
> than smoothing it.** Likes, saves, comments and makes are grouped from rows that each carry
> `created_at`, so their history goes back as far as the rows do. **Downloads come from
> `design_stats_daily`, which only began collecting on the day it shipped.** A short download line
> therefore means "we started counting recently", not "nobody downloaded it" — read `note` before
> drawing a conclusion, and do not present the two as one timeline.

`downloads.allTime` comes from a different source again (`designs.download_count`) and is **not**
comparable to `window`: it predates daily collection, so `allTime` is routinely larger than any sum
of the series. That is correct, not a bug.

**`fileServed` is not a second download count and must not be added to one.** It counts the other
three ways a model file leaves the server — the 3D preview on a design page, the NSFW preview, and a
desktop-app library sync — none of which is a person choosing to take the file. A sync in particular
repeats on every app launch, so summing the two would let a saved design out-rank a downloaded one.
It collects only from 2026-08-15, so it is `0` for every window that ends before then; `downloads`
remains the number to quote.

An account with no designs returns `designs: []` and `totals: {}` — note the **empty object**, not
zeroed metric keys. Do not index into it blindly.

### `GET /printers`

What can be verified, and what is actually here. Public.

```json
{
  "recognised": ["Snapmaker","Bambu","Prusa","Creality","Elegoo","Anycubic","Qidi","Voron"],
  "inLibrary": [ { "brand": "Snapmaker", "models": ["Snapmaker U1"], "verifiedProfiles": 4 } ],
  "unidentifiedProfiles": 2
}
```

`recognised` is a **capability** and is stable. `inLibrary` is **what exists right now** and is
usually much shorter. Do not build a filter from `recognised` — it will offer brands that return
nothing. The website hides its own printer filter entirely until `inLibrary` has more than one entry.

---

## Objects

### `DesignDTO`

```jsonc
{
  "id": "…", "slug": "desk-hook-a1b2c3", "title": "Desk hook",
  "description": "…", "creator": null,          // original creator credit, when reshared
  "license": "CC-BY", "category": "household", "subcategory": null,
  "material": "rigid",                          // rigid | flexible | multi
  "colorCount": 4, "nsfw": false,
  "createdAt": "2026-08-08T…", "downloadCount": 3,
  "url": "https://makerrun.com/designs/desk-hook-a1b2c3",
  "cover": { "url": "https://…", "aiGenerated": false, "aiSource": null },
  "external": false,                            // true = the model lives elsewhere; we only link
  "sourceUrl": null,
  "verification": {
    "badge": true,                 // the ✓ badge is shown
    "fileChecked": true,           // our server opened the file and confirmed a real profile
    "printPhotoConfirmed": false,  // a moderator confirmed a photo of the finished print
    "printer": { "brand": "Prusa", "model": "MK4S" }
  },
  "sale": {
    "kind": "both",                // file | print | both — null when free
    "url": "https://buy.stripe.com/…", "provider": "stripe",
    "price": 15, "currency": "SAR",
    "shipsFrom": "Riyadh", "leadTimeDays": 3, "note": null,
    "shipsTo": ["SA", "AE"],       // ISO 3166-1 alpha-2, or null — see below
    "platform": "external"         // always. BedReady never takes the payment.
  }
}
```

### `sale.shipsTo` — null means unrestricted, not "nowhere"

Where the creator will **post** a physical item, as opposed to `shipsFrom`, which is where it comes
from. Only meaningful when `kind` is `print` or `both`: a download has no destination, and setting
`saleShipsTo` on a file listing is a `422`.

**`null` means the creator has stated no restriction, and that is the common case.** Every listing
predates this field. Rendering "ships nowhere" or hiding a Buy button on `null` would suppress every
existing listing — a total regression that looks exactly like the feature working.

There is deliberately no `worldwide` flag. A creator who has restricted nothing and one who has
declared worldwide shipping are indistinguishable to a buyer, because neither has excluded them.

Writing it, on `POST` and `PATCH`:

```jsonc
{ "saleShipsTo": ["SA", "AE"] }   // an array, or a string: "SA, AE"
{ "saleShipsTo": null }           // clears the restriction
```

Codes are validated against ISO 3166-1 alpha-2 and the response **names the ones it rejected** rather
than saying "invalid". The United Kingdom is `GB`; `UK` is not an ISO code and is the mistake worth
expecting. Maximum 60 entries — past that, leave it empty.

The contradiction is checked against the listing as it will be **after** your write, not against the
fields you happened to send — so turning a physical listing back into a download is also a `422`:

```jsonc
{ "saleKind": "file" }   // 422 if destinations are still stored, naming them
```

Clear both in the one request — `{ "saleKind": "file", "saleShipsTo": null }` — or un-list entirely
with `{ "saleUrl": null }`, which clears the destinations, the kind and the price together.

If you show a warning to buyers outside the list, warn rather than block. IP geolocation is wrong
often enough — VPNs, travel, corporate egress — that hiding a working purchase refuses real sales with
no way for the buyer to say "I am actually here".

### Why `verification` is four fields and not one boolean

Because they are four different facts, and collapsing them is how this site ended up telling visitors
its server had confirmed files it had never read.

- `badge` — what is displayed.
- `fileChecked` — what a **machine** confirmed.
- `printPhotoConfirmed` — what a **person** confirmed.
- `printer` — which machine the profile is for. `null` means unidentifiable, **not** "assume U1".

A listing can hold any combination, including none. An **external** listing can never be
`fileChecked` — there is no file here to read.

---

## Errors

```json
{ "error": { "code": "invalid", "message": "Some fields were rejected.",
             "details": [ { "field": "saleUrl", "message": "…" } ] } }
```

| status | code | meaning |
|---|---|---|
| 400 | `bad_request` | malformed body or missing required part |
| 401 | `unauthorized` | missing, malformed or expired token |
| 403 | `forbidden` / `rejected` | not yours, or refused by row-level security |
| 404 | `not_found` | no such listing, or not published |
| 409 | `conflict` | the listing's state forbids it (e.g. hosting a file on an external listing) |
| 422 | `invalid` | validation — see `details[]`, each with a `field` |
| 403 | `mfa_required` | 2FA is on and this session has not completed it |
| 429 | `rate_limited` | slow down |
| 503 | `unavailable` | server not configured |
| 503 | `maintenance` | planned downtime — the whole site is closed, see below |

### `503 maintenance` — retry, do not treat as an error state

During a hand-applied database migration the entire site returns `503` with:

```json
{ "error": { "code": "maintenance", "message": "…" } }
```

Every endpoint, reads included, and `/auth` too. A `Retry-After` header carries the number of seconds
to wait — honour it rather than backing off on your own schedule.

**Do not surface this as a failure or discard queued work.** Nothing has been lost and nothing was
half-written; that is the point of closing the door. Show "BedReady is briefly unavailable", keep the
user's draft, and retry after `Retry-After`.

Branch on `error.code`, never the status: `503` is also `unavailable`, which means the server is
misconfigured and retrying will not help.

This document stays up during a window — `docs.makerrun.com` is exempt from the gate — so the page
explaining the outage is readable while the outage is happening.

## Limits

| | |
|---|---|
| Model file | 100 MB, `.3mf` `.stl` `.obj` `.step` `.stp` |
| Image | 15 MB, `png` `jpeg` `webp` `gif` `avif` |
| Images per request | 8 |
| Uploads | 20 files / 40 images per 10 min, per user |
| `limit` on list endpoints | 100 |

CORS is open for `GET`, `POST`, `PATCH`, `DELETE`, `OPTIONS` — everything is either public data or
gated behind the caller's own token.

---

## Things that will bite you

**`status` cannot be set.** Creating a listing does not publish it. Show "in review" and poll `GET /me`.

**Absent ≠ null on PATCH.** Absent leaves a field alone; `null` clears it. Clearing `saleUrl` un-lists
the design.

**A price needs a link.** `salePrice` without a `saleUrl` — on create, or on a listing that has none —
is a `422`. A price with nowhere to pay is a number nobody can act on.

**Verification is not the same as the badge.** Read `fileChecked` and `printPhotoConfirmed`
separately. `printer: null` does not mean U1.

**`kind=print` is a claim, not a confirmation.** Only a moderator's confirmation earns the tag.

**Metadata stripping is not optional and not a client concern.** Do not strip before uploading and do
not assume the bytes you sent are the bytes stored — they are not, for any image carrying metadata.

**Do not build a filter from `recognised`.** Use `inLibrary`.

**Handle `mfa_required` separately from `forbidden`.** Both are `403`; only one is fixed by completing
a challenge and retrying. Branch on `error.code`, not on the status.

**Render `downloadsNote`.** Its absence from the timeline is a property of the data, not a bug, and a
creator will misread it otherwise.

**Uploading a file does not make a listing complete.** A design with no cover image will look empty
in the library. Upload at least one image.
