BedReady 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://bedready.io/api/v1
This document docs.bedready.io — raw markdown at
docs.bedready.io/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 BedReady repository at build time; the two cannot disagree.
Contents
- Why this exists instead of talking to Supabase
- Authentication
- Two-factor authentication
- Publishing: the whole sequence
- Selling: what this API does and does not do
- Endpoints
- 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".
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). 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 |
{
"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.
{
"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.
{ "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://bedready.io/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.
POST /designs/{slug}/images
multipart/form-data, one or more images fields, optional kind (gallery | print).
curl -X POST https://bedready.io/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.
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 /printers
What can be verified, and what is actually here. Public.
{
"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
{
"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://bedready.io/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.
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.bedready.io 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.