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.iountil the project split in two: the converter is now open-source atbedready.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 tomakerrun.com/api/*rather than redirected, so method, body andAuthorizationall survive the hop. Treat it as a compatibility alias and usemakerrun.com.
This document docs.makerrun.com — raw markdown at
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.iotodocs.makerrun.com. It had been left on the converter's domain after the split, which the deployment split then made plainly wrong:bedready.iois 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.ionow 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 atdocs.makerrun.com/api.mdso you are reading the canonical address rather than following a redirect.
Contents
- Why this exists instead of talking to Supabase
- Authentication
- Desktop apps: linking, and staying signed in —
/app-link,POST /api/app-token
- Desktop apps: linking, and staying signed in —
- Two-factor authentication
- Publishing: the whole sequence
- Selling: what this API does and does not do
- Endpoints
GET /sponsor— who is sponsoring right now;{ "sponsor": null }is normalGET /api/library— desktop sync:updatedAt,checksum,?since=
- Objects
- Errors
- Limits
- 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
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:
- 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. - The user signs in if needed, and presses Connect. The page hands back:
bedready://auth#access_token=…&refresh_token=…&expires_at=…&state=<nonce> - The app MUST validate
stateagainst the nonce it minted, and reject the callback if it does not match. Without that check, any page on the machine can fire abedready://URL and the app will consume whatever tokens it carries.stateis omitted by very old builds; treat absent as a failed handshake rather than as permission. - 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
statecheck 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).
POST /api/app-token
{ "refresh_token": "…" }
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:
{ "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. 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, streampay.sa, salla.sa, *.zid.store,
ko-fi.com, etsy.com and *.etsy.com). Anything else is rejected with a 422.
sale.provider is one of stripe, gumroad, lemonsqueezy, payhip, streampay, salla, zid,
kofi, etsy, or null. Treat it as an open set. Payhip was added on 2026-08-09, StreamPay on
2026-08-16 (and missing from this list until 2026-09-26, which is exactly the failure an exhaustive
switch turns into a crash), and the four storefronts on 2026-09-26. A client that switches on it
should have a default branch rather than assume any fixed count.
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 |
{
"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.
An offset past the end is an empty page, not an error. It answers 200 with designs: [] and
the real total, so a client that pages by incrementing offset stops on its own. Until
2026-08-25 this returned 500 query_failed.
GET /designs/{slug}
One design plus its files, images and print profiles. Public.
{
"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 — every download goes through a door that counts it
and applies the gates, so there is nothing to hand out here. To fetch one, ask
GET /designs/{slug}/files/{filename}/download for a
short-lived signed URL.
POST /designs
Create a listing. Requires a token. Returns 201.
{ "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.
curl -X POST https://makerrun.com/api/v1/designs/desk-hook-a1b2c3/files \
-H "Authorization: Bearer $TOKEN" \
-F "file=@desk-hook.3mf"
{
"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.
GET /designs/{slug}/files/{filename}/download
The route from a token to the bytes. Requires a token. filename is the one GET /designs/{slug}
reports; URL-encode it.
curl https://makerrun.com/api/v1/designs/desk-hook-a1b2c3/files/desk-hook.3mf/download \
-H "Authorization: Bearer $TOKEN"
{ "url": "https://…signed…", "expiresInSeconds": 300,
"filename": "desk-hook.3mf", "sizeBytes": 812344 }
You get a URL, not the file. It is yours to fetch — so progress, abort, resume and timeout stay with your client — and it expires in 300 seconds. Do not store it; ask again.
The same gates the website applies, in the same order:
| status | code | when |
|---|---|---|
| 401 | unauthorized | missing or expired token |
| 403 | mfa_required | 2FA is on and this session has not completed it |
| 403 | age_required | the design is marked 18+ and this account has not confirmed — send the user to /age on the website |
| 403 | forbidden | the design is creator-gated and you have not been allowed |
| 404 | not_found | no such design or file, or the listing is not published and not yours |
| 429 | rate_limited | 120 per 10 minutes, per user |
| 503 | unavailable | the server could not sign the file |
age_required is the only code unique to this endpoint, and only because the website answers that
case with a redirect to /age, which a desktop client cannot follow. It applies to NSFW designs
only — an ordinary file needs an account and nothing else, so do not gate your own UI on age
before calling. Show the licence — it is on the DesignDTO — before you call this.
Counted exactly once per design per hour, shared with the website: fetching the same file through the app and through the site inside that hour is one download, not two.
POST /designs/{slug}/images
multipart/form-data, one or more images fields, optional kind (gallery | print).
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"
{
"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.
{
"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 |
{
"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. |
{
"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 fromdesign_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" — readnotebefore 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.
{
"recognised": ["Snapmaker","Bambu","Prusa","Creality","Elegoo","Anycubic","Qidi","Voron","SPARKX"],
"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.
GET /sponsor
Who is sponsoring the site right now, if anyone. Public, no token.
{ "sponsor": { "id": "…", "name": "Filamentum", "url": "https://…", "logo_url": null,
"tagline": "Filament that behaves", "starts_at": "…", "ends_at": "…" } }
{ "sponsor": null } with a 200 is the normal answer, not an error — most of the time nobody
is sponsoring, and a 404 would make that ordinary state look like a broken deployment.
There is at most one, ever. Not a convention: overlapping bookings are refused by an EXCLUDE
constraint in the database, so this is a single object rather than an array by construction. Nothing
about a sponsorship is editorial — it never affects what is listed, ranked, compared or verified,
and it can never grant or imply the verified badge.
The row is the same one the site's own banners render, and it carries nothing an anonymous visitor
could not already read off the page. What a sponsor paid is never exposed here or anywhere else
outside /admin.
GET /api/library — a signed-in user's saved designs, for syncing
Not under /v1, and it is the one endpoint a desktop client polls repeatedly. Returns the
caller's own saved (favourited) designs with short-lived signed download URLs. Auth is a Supabase
Bearer token or the browser cookie session; results are always scoped to the caller.
Undocumented until 2026-08-28, which is part of why the Khayt desktop app had to infer the shape and
ended up reading it as Array.isArray(data.items) ? data.items : [] — so a renamed field would have
shown a shop an empty library and told them nothing. The field names below are a contract;
src/lib/library-contract.test.mts pins them, and changing one is a breaking change rather than a
rename that type-checks.
{
"items": [ /* … */ ],
"count": 3,
"filtered": true, // was `?since=` applied? see below
"syncedAt": "2026-08-28T07:41:02.113Z", // pass this back as the next `?since=`
"removed": ["…designId", "…"], // what LEFT since `since`. null on a full sync.
"removalsCompleteSince": "2026-03-01T…",// `removed` is only complete for a `since` after this
"total": 250, // every design this user has saved
"offset": 0, // where this page starts; pass `?offset=` for the rest
"truncated": true // another page exists — see the warning below
}
Each item:
{
"designId": "…", "slug": "nfc-filament-tags-b4432c", "title": "NFC Filament Tags",
"listingKind": "hosted", // hosted | linked | print — see below
"url": "https://makerrun.com/designs/nfc-filament-tags-b4432c",
"cover": "https://…", "nsfw": false,
"savedAt": "2026-07-02T…", // when the user saved it
"updatedAt": "2026-08-14T…", // when the DESIGN last changed — title, description, licence
"fileUpdatedAt": "2026-06-30T…", // when the FILE's bytes last changed
"checksum": "d276555b1b8307606f9e2ad14d293f76",
"checksumAlgo": "md5", // ALWAYS present — it fingerprints nothing
"filename": "NFC Tags.3mf", "fileType": "3mf", "sizeBytes": 935104,
"downloadUrl": "https://…", // signed, 1 hour
"license": "CC0-1.0",
"commercialUse": true, // true | false | null — see below
"print": { // the designer's own numbers, or null
"printer": "u1", "layerHeightMm": 0.12, "filamentTypes": ["PLA"],
"colorCount": 2, "material": "rigid",
"nozzleDiametersMm": [0.4], // every file carries this
"plateCount": 7, // most do
"printTimeSeconds": 6990, // best available — see the three sources below
"printTimeSource": "declared", // "declared" | "file" | "sliced" — READ THIS
"filamentGrams": 43.7,
"filamentGramsSource": "sliced",
"filamentGramsConfidence": "high", // only on a weight WE derived; absent when it was read
"fromVerifiedProfile": true // came out of a file we opened, not a form somebody filled in
},
"gated": false, "gatedReason": null // "age" | "vault" | null
}
A delta says what left, not only what changed. A design that leaves your library is absent
from a delta — and so is every design that did not change. Same absence, two meanings, so a non-empty
delta used to be unusable on its own. removed closes that: it lists the design ids that were
unpublished, deleted, or unsaved since your cursor, so the changed case costs one request instead
of two. It is null on a full sync, where the list itself is already the complete answer.
removalsCompleteSince is the retention boundary, stated rather than assumed. Tombstones are pruned
at 180 days, so if your since is older than this value, removed is not complete for it and
you must fall back to a full sync. Silently returning a short list would be the same lie as the
absence it replaces.
Syncing without re-downloading everything. Pass the previous response's syncedAt back as
?since= and the list narrows to designs whose listing or file changed after it — plus anything
newly saved. count: 0 with filtered: true means nothing changed; count: 0 with
filtered: false means the user has saved nothing. They are different answers and the endpoint says
which. An unparseable since is a 400, never a silent full sync.
Verifying a download. checksum is the storage layer's own MD5 of the object, so it cannot drift
from the bytes. Compare it after downloading, and skip files whose checksum and fileUpdatedAt you
already hold. It is null for a gated design — an eTag fingerprints bytes behind the vault, and two
identical gated files would otherwise be identifiable as such. checksumAlgo is returned anyway,
so "gated, so no digest" is distinguishable from "we could not compute one".
sizeBytes follows the gate that withheld it rather than a blanket rule. A vault-gated design
keeps its size: its page, title and cover are public on the site, so the number adds nothing a
visitor cannot already see, and it lets a shop show what unlocking would cost. An age-gated one
withholds it, because that gate withholds the cover too. A checksum stays null for both — unlike a
size, it is a fingerprint of the content.
commercialUse has three values and one of them is null. designs.license is free text, so
this API reads only the licences it knows plus the Creative Commons NC clause, which means the same
thing in every version. Anything else returns null, meaning read the licence — a deliberate
non-answer, because guessing false blocks a shop from a print it may sell and guessing true tells
it to sell one it may not. A true is only ever about the commercial clause; attribution,
share-alike and no-derivatives still bind.
⚠️ A truncated page is not authoritative for deletion
total, offset and truncated were added on 2026-08-28 because this endpoint capped at 200 and
said nothing about it. A shop with 250 saved designs received 200 with no way to know — and once
removed made a delta authoritative, that silence became data loss: a client mirroring a full sync
would treat the 50 it never received as gone.
Only delete what a sync did not return when truncated is false. Page with ?offset= until it
is.
listingKind — say what kind of listing it is
hosted (the file is here) · linked (the file is elsewhere, at an allowlisted library) · print
(a printed object, no file at all — a commercial print is not permitted to share one).
It is a field, not an inference. A print-only listing arrives with filename, fileType,
checksum and downloadUrl all null — and so does a design whose file failed to resolve. Same
absence, two meanings, which is the shape everything else on this page has been changed to avoid.
Derived from whether a file actually exists, not from sale_kind: that is a statement about
selling, and it would relabel a hosted design the moment its author offered printed copies of it.
Not to be confused with the print object below — listingKind says what the listing is,
print carries the recommended settings for printing it. The second is answerable for a hosted
design nobody is selling.
The same field is on DesignDTO under /v1, where it is null if the endpoint did not look files
up. The seller's own shop lives on their profile, never on the listing. See ROADMAP §11.
What is in print, and how often — measured, not assumed
Coverage over all 23 files in samples/, after the extractor learned to read them — re-measure with
npm run facts, which is a command rather than a CI gate because samples/ is 485 MB of third-party
files and gitignored:
| field | coverage | why |
|---|---|---|
filamentTypes | see below | what the print uses, which is not the spool list — see below |
nozzleDiametersMm | 23/23 | both slicer families carry it — Orca as a JSON array, PrusaSlicer as a comma list in the INI |
plateCount | 20/23 | from model_settings.config. The three misses are Prusa, whose container has no plate concept |
printTimeSeconds | 2/23 | written only when a project is saved after slicing |
filamentGrams | 2/23 | same condition |
Those last two rows are the coverage of the file alone, which was the only source until 2026-08-29. They now resolve through three, so they are present far more often than the table says — see below.
Build for absence. nozzleDiametersMm and plateCount are facts about nearly every file and
come only from the file. Every field is omitted rather than zeroed — 0 minutes is a claim about
the print, absent is an admission that nobody said.
printTimeSource and filamentGramsSource — read these, do not ignore them
Time and weight resolve through a precedence, and each resolves independently: a maker who stated
a time and no weight gives you "declared" for one and "sliced" for the other in the same object.
| source | what it means |
|---|---|
declared | The maker typed it. They printed the thing; nothing beats that. |
file | The file carried a slice result — their slicer, their profile, their machine. |
sliced | MakerRun sliced it, on one pinned profile (Snapmaker U1, 0.4 nozzle, 0.20 layer, PLA). |
These are not interchangeable, and displaying them identically misrepresents them. A file time
is what that designer actually observed, and is meaningless compared against another listing's,
because the two came off different profiles. A sliced time is the opposite: directly comparable
across every listing precisely because it is always our profile, and correspondingly not what anyone
will see on their own machine. Showing a sliced number without saying so attributes our arithmetic
to the designer.
If you show only one number, show it with its source. If you sort or compare across listings, use
sliced rows only.
filamentGramsConfidence ("high" | "medium" | "low") appears only when we derived the weight
from a volume and a material density rather than reading it. Filled and foaming filaments vary enough
by brand that "low" is worth a qualifier in the UI. Absent means the number was read, not computed.
printTimeSeconds and filamentGrams are summed over plates regardless of source.
An eight-plate project in samples/ reports ~28 hours and 893 g for the whole job; reporting one
plate would understate it eightfold.
filamentTypes is what the PRINT uses — corrected 2026-09-01
It used to be the spool list, and that is a fact about the machine. The project config stores
filament_type beside filament_colour, one entry per loaded spool, and a Snapmaker U1 carries four
whatever the model needs. This field returned that array verbatim, so a one-colour print answered
["PLA","PLA","PLA","PLA"] — while the example above has always said ["PLA"]. If you deduplicated
it, you got the right answer by accident; if you counted it, you were counting spools.
It now names the materials the print actually uses, read from the slots the file says are assigned, deduplicated, in slot order. A Full Spectrum print is the exception and reports every loaded material, because mixing means it touches all of them.
Absent rather than guessed, as everywhere else in print. [] means the file did not say which
slots it uses and the loaded spools were not all the same material — so naming them would tell you
to load a filament the print may never touch. Every profile stored before 2026-08-26 is in that
position unless its loadout was uniform.
Where the file yields nothing, the maker's own free-text filament note is parsed instead — "PLA, PETG" becomes ["PLA","PETG"]. That box accepts prose, so it is all-or-nothing: if any part of it
is not a filament name, the field is [] rather than a sentence fragment. It was previously shipped
as a single-element array containing the raw string, which made filamentTypes.includes("PLA")
false on a PLA print.
Why nozzleDiametersMm is an array and not the nozzleDiameterMm that was asked for. A
toolchanger names one per tool. Identical values are deduped — eight 0.4s are one fact about the
machine, not eight — so a single-nozzle machine gives you [0.4] and the common case is
print.nozzleDiametersMm[0]. A scalar would have been a lie on exactly the machines this library
exists to open up.
Objects
DesignDTO
{
"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:
{ "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:
{ "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.nullmeans 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
{ "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:
{ "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 |
| Listings created | 20 per 10 min, per user |
Downloads (…/files/{filename}/download) | 120 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.
An account is required for every download; an age confirmation is not. age_required comes back
only for a design whose nsfw is true. Treating it as universal — which the website itself did until
2026-08-24 — puts an 18+ prompt in front of a CC0 coaster.
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.
hosted: true is not a link. There is no storage path anywhere in this API, by design. Call
GET /designs/{slug}/files/{filename}/download and fetch the signed URL it returns — within 300
seconds, and without caching it.
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.