# GalleryCamp API > REST API for GalleryCamp, gallery management software covering artists, > artworks, customers, exhibitions, inquiries, orders and invoices. Generated from source on 2026-10-07. Do not edit by hand. ## Conventions - Base URL: http://localhost:3006 - All request and response bodies are JSON (`Content-Type: application/json`). - Successful responses are shaped `{ "success": true, "data": ... }`. List endpoints add `{ "meta": { total, page, limit, total_pages } }`. - Errors are shaped `{ "success": false, "error": { "code": "...", "message": "..." } }`. Some older routes return `{ "error": "message" }` — treat both as failures. - Pagination uses `?page=` (1-based) and `?limit=` query parameters. - Timestamps are ISO 8601 strings. Money fields are numbers with a separate `currency` string (ISO 4217). ## Authentication - `session` endpoints require an authenticated session cookie, issued by the Better Auth endpoints under `/api/auth/*`. - Most session endpoints also require an **active organization**; the session carries it. Requests without one get 401/403. - Endpoints marked `public` need no credentials — these are the public gallery read endpoints and health checks. - API keys (prefix `gc_`) belong to an organization and are sent as a bearer token for machine access. ## Endpoints ### account #### POST /api/account/avatar POST /api/account/avatar — multipart form with a single `file`. Returns the public URL; the profile page then stores it on the user through Better Auth's `updateUser`, so the session's `user.image` refreshes with it. Files land in `/avatars/` like every other upload — the bucket is keyed by organization, and a user always uploads from inside one. - Auth: public #### POST /api/account/password POST /api/account/password — give an account its first password. Someone who signed up through Google has no `credential` account, so there is no current password for Better Auth's `changePassword` to check. Its `setPassword` endpoint covers exactly that case, but the plugin does not route it publicly (it has no path — it is only reachable through `auth.api`), so the profile page goes through here instead. The session comes from the forwarded cookies, never from the body: this can only ever set the caller's own password. Better Auth refuses if a password already exists, which keeps this from becoming a way to skip the current-password check. - Auth: public - Body fields: newPassword ### admin #### GET /api/admin/accounts - Auth: public #### GET /api/admin/accounts/{id} GET /api/admin/accounts/:id — one gallery, in full. What support needs when a gallery gets in touch, and what tells you whether an account is thriving or quietly dying: what they pay, what they have put in, what they have sold through it, who is on the team, and when anyone last touched it. Read-only. Changing what a gallery pays is done in Stripe, where it is attributed to a Stripe user and lands in their audit log — a subscription silently edited from an internal screen is something nobody can reconstruct six months later. - Auth: public - Path parameters: id #### POST /api/admin/accounts/{id}/comp POST /api/admin/accounts/:id/comp — give a gallery their account for free. The only write in the admin area, and the exception to its read-only rule. It earns that by being fully attributed: who did it, when, and why are all recorded on the subscription and in the gallery's own audit log, so the decision can be traced long after the person who made it has moved on. - Auth: public - Path parameters: id - Body fields: price_id, reason #### DELETE /api/admin/accounts/{id}/comp DELETE /api/admin/accounts/:id/comp — make the account billable again. Removes the discount only. Nothing is charged for the periods that were comped: those were genuinely free, and billing them retroactively would be indefensible. - Auth: public - Path parameters: id #### POST /api/admin/accounts/{id}/credit POST /api/admin/accounts/:id/credit — put credit on a gallery's account. Held as Stripe's customer balance, so it comes off the next invoice automatically whatever that invoice turns out to be. Nothing is charged or refunded here. Attributed like comping, and for the same reason: this moves money, and "why does this account have $400 of credit?" needs an answer that outlasts whoever granted it. - Auth: public - Path parameters: id - Body fields: amount, direction, reason ### artist-categories #### GET /api/artist-categories - Auth: session (active organization required) - Query parameters: limit, page, search #### POST /api/artist-categories Create an artist category, or hand back the one that already says this. The artist form creates from a combobox, where "Represented" is typed by whoever is adding the artist rather than picked from a fixed list — so the same name arriving twice is the normal case, not an error. Returning the existing row keeps that idempotent: two people adding artists at once end up on one category instead of one of them seeing a failed save. - Auth: session (active organization required) - Body fields: description, name, slug #### GET /api/artist-categories/{id} - Auth: session (active organization required) - Path parameters: id #### PUT /api/artist-categories/{id} - Auth: session (active organization required) - Path parameters: id - Body fields: description, name #### DELETE /api/artist-categories/{id} - Auth: session (active organization required) - Path parameters: id #### POST /api/artist-categories/{id}/merge Fold one artist category into another. Free text drifts: a gallery ends up with "Secondary market" and "Secondary Market" and no way to tell the two apart on a list. Deleting one loses the categorisation of everyone in it, so the only useful cleanup is a move — every artist is repointed at the survivor, and the emptied category goes. The whole thing runs in one transaction. A half-done merge would leave artists filed under a category that no longer exists. - Auth: session (active organization required) - Path parameters: id - Body fields: into ### artists #### GET /api/artists - Auth: session (active organization required) - Query parameters: category_id, limit, page, search, sortBy, sortOrder #### POST /api/artists - Auth: session (active organization required) #### GET /api/artists/{id} - Auth: session (active organization required) - Path parameters: id #### PUT /api/artists/{id} - Auth: session (active organization required) - Path parameters: id #### DELETE /api/artists/{id} - Auth: session (active organization required) - Path parameters: id #### GET /api/artists/{id}/images - Auth: session (active organization required) - Path parameters: id #### POST /api/artists/{id}/images - Auth: session (active organization required) - Path parameters: id #### PUT /api/artists/{id}/images/{imageId} - Auth: session (active organization required) - Path parameters: id, imageId - Body fields: is_primary, order #### DELETE /api/artists/{id}/images/{imageId} - Auth: session (active organization required) - Path parameters: id, imageId #### PATCH /api/artists/{id}/images/{imageId}/caption - Auth: session (active organization required) - Path parameters: id, imageId - Body fields: caption #### PATCH /api/artists/{id}/images/{imageId}/primary - Auth: session (active organization required) - Path parameters: id, imageId #### POST /api/artists/bulk-categories Add or remove artist categories across a selection. Categorising a roster is a sitting-down job, not something done one artist at a time: a gallery decides to mark its twelve represented artists, or moves a group onto the secondary market after a programme change. Doing that one-by-one is exactly where the vocabulary drifts apart. The mode is required rather than inferred, for the reason the artwork version gives: "update the categories on these twelve" has three genuinely different meanings and guessing wrong silently destroys work. `add` leaves what is there alone, `remove` takes one term off a mis-tagged batch, and `replace` is the only one that discards — so it is the one the caller must ask for by name. - Auth: session (active organization required) #### POST /api/artists/bulk-delete - Auth: session (active organization required) - Body fields: artistIds #### POST /api/artists/import - Auth: session (active organization required) - Body fields: artist, images ### artworks #### GET /api/artworks - Auth: session (active organization required) - Query parameters: archived, artist_id, category_id, group, limit, location_id, medium, online_store, page, search, status #### POST /api/artworks - Auth: session (active organization required) #### GET /api/artworks/{id} - Auth: session (active organization required) - Path parameters: id #### PUT /api/artworks/{id} - Auth: session (active organization required) - Path parameters: id #### DELETE /api/artworks/{id} - Auth: session (active organization required) - Path parameters: id #### GET /api/artworks/{id}/appraisals GET /api/artworks/:id/appraisals — the valuation history, newest first. Newest first because the first row is the answer to "what is this insured for?", and the rest is how that number moved. - Auth: session (active organization required) - Path parameters: id #### POST /api/artworks/{id}/appraisals POST /api/artworks/:id/appraisals — record a valuation. The currency defaults to the work's own rather than to USD: a gallery pricing in euros that gets a valuation back in euros should not have to restate it, and defaulting to dollars would put the wrong symbol on a schedule that an insurer reads. - Auth: session (active organization required) - Path parameters: id - Body fields: appraiser, currency, effective_at, expires_at, notes, purpose, reference, value #### DELETE /api/artworks/{id}/appraisals/{appraisalId} DELETE /api/artworks/:id/appraisals/:appraisalId For one entered in error. There is deliberately no update: correcting a valuation by editing it would rewrite what the work was insured for last year, and the history is the whole reason the record exists. A revised figure is a new appraisal with a later effective date. - Auth: session (active organization required) - Path parameters: id, appraisalId #### GET /api/artworks/{id}/collections - Auth: session (active organization required) - Path parameters: id #### POST /api/artworks/{id}/collections - Auth: session (active organization required) - Path parameters: id #### DELETE /api/artworks/{id}/collections/{collectionId} - Auth: session (active organization required) - Path parameters: id, collectionId #### GET /api/artworks/{id}/condition GET /api/artworks/:id/condition — the inspection history, newest first. Newest first because the first row is the answer to "what condition is this in?", and the rest is how it got there. - Auth: session (active organization required) - Path parameters: id #### POST /api/artworks/{id}/condition POST /api/artworks/:id/condition — record an inspection. Always a new report, never an amendment to the last one: a report is evidence of what was seen on a date, and editing history into agreement with the present is exactly what a condition record must not do. - Auth: session (active organization required) - Path parameters: id - Body fields: context, inspected_at, notes, rating, summary #### POST /api/artworks/{id}/condition/{reportId} POST /api/artworks/:id/condition/:reportId — attach a photograph. Multipart, matching the artwork image endpoint, so the same upload control works for both. - Auth: session (active organization required) - Path parameters: id, reportId #### DELETE /api/artworks/{id}/condition/{reportId} DELETE /api/artworks/:id/condition/:reportId Removes the report and the photographs stored for it. A condition report is evidence, so this is for one raised in error — the UI does not offer it once a report has been superseded. - Auth: session (active organization required) - Path parameters: id, reportId #### GET /api/artworks/{id}/edition GET /api/artworks/:id/edition — the run this work belongs to, or null. Null rather than 404 for a unique work: not being in an edition is an ordinary answer, and the artwork screen asks this of every piece it opens. - Auth: session (active organization required) - Path parameters: id #### POST /api/artworks/{id}/hold POST /api/artworks/:id/hold — take a work off the market for a collector. Refuses a work that has already sold. Refuses one already held for somebody else, because quietly moving a hold is exactly how two collectors end up believing they have the same piece. - Auth: session (active organization required) - Path parameters: id - Body fields: customer_id, until #### DELETE /api/artworks/{id}/hold DELETE /api/artworks/:id/hold — put it back on the market. - Auth: session (active organization required) - Path parameters: id #### GET /api/artworks/{id}/images - Auth: session (active organization required) - Path parameters: id #### POST /api/artworks/{id}/images - Auth: session (active organization required) - Path parameters: id #### DELETE /api/artworks/{id}/images/{imageId} - Auth: session (active organization required) - Path parameters: id, imageId #### PATCH /api/artworks/{id}/images/{imageId}/caption - Auth: session (active organization required) - Path parameters: id, imageId - Body fields: caption #### PATCH /api/artworks/{id}/images/{imageId}/primary - Auth: session (active organization required) - Path parameters: id, imageId #### GET /api/artworks/{id}/orders - Auth: session (active organization required) - Path parameters: id #### GET /api/artworks/{id}/provenance GET /api/artworks/:id/provenance — the ownership chain, earliest first. - Auth: session (active organization required) - Path parameters: id #### POST /api/artworks/{id}/provenance POST /api/artworks/:id/provenance — add a link to the chain. - Auth: session (active organization required) - Path parameters: id - Body fields: detail, from_year, is_current, owner, to_year #### PUT /api/artworks/{id}/provenance PUT /api/artworks/:id/provenance — reorder the chain. - Auth: session (active organization required) - Path parameters: id - Body fields: order #### PATCH /api/artworks/{id}/provenance/{entryId} - Auth: session (active organization required) - Path parameters: id, entryId - Body fields: detail, from_year, is_current, owner, to_year #### DELETE /api/artworks/{id}/provenance/{entryId} - Auth: session (active organization required) - Path parameters: id, entryId #### GET /api/artworks/{id}/quick-view GET /api/artworks/:id/quick-view — everything the artwork popover shows. One request rather than three. The popover opens from a thumbnail in a list, which is a place people click quickly and repeatedly — three round-trips would make it feel like a page load, and the whole point of it is that it is not one. The counts are deliberately whole-history while the trend is windowed: a work that has had two hundred views over two years and none this month is a different thing from one nobody has ever looked at, and a single number cannot say which. - Auth: session (active organization required) - Path parameters: id #### GET /api/artworks/{id}/views - Auth: session (active organization required) - Path parameters: id - Query parameters: days #### POST /api/artworks/bulk-archive Archive or restore artworks in bulk. Archiving is not deletion: the row, its images and its history all stay put. It only drops the piece out of the default list, which is why this is an update rather than anything destructive. - Auth: session (active organization required) - Body fields: archived, artworkIds #### POST /api/artworks/bulk-delete - Auth: session (active organization required) - Body fields: artworkIds #### POST /api/artworks/bulk-edit Set the medium, add and remove categories, or put works on or off the online store, across a selection. Cataloguing happens in batches — a crate arrives, forty prints get entered, and every one of them is "Screenprint on paper" filed under Printmaking. Doing that one artwork at a time is where the medium spellings drift apart in the first place, so this exists as much to keep the vocabulary tight as to save the clicks. Categories carry a mode because "update the categories on these forty" has three genuinely different meanings, and guessing wrong silently destroys work. `add` leaves what is already there alone, `remove` takes one term off a mis-tagged batch, and `replace` is the only one that discards — so it is the one the caller has to ask for by name. - Auth: session (active organization required) #### GET /api/artworks/facets The vocabulary a gallery has actually used, for the inputs that offer it. Both the artwork form's suggestions and the list's filters need to know every medium and category in the collection, not the ones that happen to be on screen. The list previously built its medium list from the loaded page, so a medium used only by the 300th artwork was unfilterable — the filter simply did not offer it. Counts come back with each entry: they are what tells a gallerist that "Oil on canvas" (128) is the house spelling and "oil on Canvas" (1) was a typo, which is the whole point of suggesting rather than free-typing. - Auth: session (active organization required) #### POST /api/artworks/import - Auth: session (active organization required) - Body fields: artist_external_ref, artwork, categories, collections, images, note ### audit #### GET /api/audit History for one record, newest first. Scoped to the caller's organization, so an entity id from another gallery returns nothing rather than leaking its history. - Auth: session (active organization required) - Query parameters: entity_id, entity_type, limit, offset ### auth #### GET /api/auth/* - Auth: public #### POST /api/auth/* - Auth: public ### billing #### GET /api/billing GET /api/billing — everything the billing page renders, in one call. Deliberately one round trip rather than six. The page shows the plan, the usage, the card, the next invoice and the history together, and loading them separately means five spinners resolving at different moments on a screen where people are already anxious about money. The Stripe-backed pieces are fetched concurrently and each degrades on its own: a failure listing cards must not blank the plan the gallery is on. - Auth: public #### POST /api/billing/cancel POST /api/billing/cancel — stop the subscription renewing. Never an immediate cancellation. The gallery has paid through the end of the period and pulling access the moment they click cancel would be charging them for time they do not get. Stripe closes the subscription at the period end and the webhook records it. DELETE is the same operation, for callers that prefer the verb. - Auth: public #### DELETE /api/billing/cancel - Auth: public #### POST /api/billing/change-plan POST /api/billing/change-plan — move to a different tier. Takes effect immediately, with Stripe prorating: an upgrade bills the difference for the rest of the period now, a downgrade leaves a credit against the next invoice. /api/billing/preview shows that number first. A downgrade below the gallery's current artwork count is allowed. Their archive is their record of their own business and must never be locked or culled to fit a smaller plan — the limit stops new artworks being added, and that is all it does. - Auth: public - Body fields: price_id #### POST /api/billing/checkout POST /api/billing/checkout — start a subscription. Returns the client secret for an embedded Checkout session, which the billing page mounts in a dialog. The gallery never leaves the app, and we never touch a card number. Only for a first subscription. A gallery that already has one changes it through /api/billing/change-plan, where the proration is shown before anything is charged. - Auth: public - Body fields: price_id, theme #### GET /api/billing/invoices GET /api/billing/invoices — the gallery's invoice history. Rendered as a table in the app. Each row carries Stripe's hosted invoice URL and PDF link, which is the one place a gallery does leave for: the PDF is generated and served by Stripe, and proxying it would mean re-rendering their tax-compliant document ourselves for no benefit. - Auth: public - Query parameters: limit #### POST /api/billing/payment-method POST /api/billing/payment-method — a SetupIntent for adding a card. The client secret is mounted into a Stripe Payment Element inside a dialog on the billing page. The card fields themselves are an iframe served by Stripe — that part cannot happen in our own DOM without taking on PCI scope — but the dialog, the buttons and the result all belong to the app. - Auth: public #### PATCH /api/billing/payment-method PATCH /api/billing/payment-method — make a card the default. Set on the customer's invoice settings, which is what subscription renewals actually charge. - Auth: public - Body fields: payment_method_id #### DELETE /api/billing/payment-method DELETE /api/billing/payment-method — remove a card. Refuses to remove the last one while a subscription is live. Stripe would accept it and then decline the next renewal, putting the gallery into dunning for something they did in a settings screen without being warned. - Auth: public #### GET /api/billing/plans GET /api/billing/plans — the published tiers. Public and unauthenticated: this is what the pricing page and the upgrade dialog read, so what is advertised and what is charged come from the same declaration in src/lib/plans.ts. - Auth: public #### POST /api/billing/preview POST /api/billing/preview — what changing plan costs today. Shown before the change is committed. A mid-cycle upgrade charges a prorated amount that is never the sticker price, and a gallery that sees an unexplained number appear on their card reads it as a billing error and emails support. Nothing is created by this call. - Auth: public - Body fields: price_id #### POST /api/billing/promo POST /api/billing/promo — apply a discount code. Codes are created in the Stripe dashboard, which already handles percentage versus fixed amounts, duration, redemption caps and expiry. Building a creation UI on top of that would be work for no gain. The code is looked up before it is applied so an invalid one comes back as a sentence the gallery can act on, and so "no such code" reads differently from "that code has expired". - Auth: public - Body fields: code #### DELETE /api/billing/promo DELETE /api/billing/promo — take the discount back off. - Auth: public #### POST /api/billing/resume POST /api/billing/resume — undo a scheduled cancellation. Only works while the period is still running. Once Stripe has closed the subscription there is nothing to resume and the gallery starts a new one through checkout. - Auth: public ### blog #### GET /api/blog/categories - Auth: session (active organization required) - Query parameters: limit, page, search #### POST /api/blog/categories Create a blog category, or hand back the one that already says this. The post form creates from a combobox, where "Studio Visits" is typed by whoever is writing rather than picked from a fixed list — so the same name arriving twice is the normal case, not an error. Returning the existing row keeps that idempotent: two people writing at once end up on one category instead of one of them seeing a failed save. - Auth: session (active organization required) - Body fields: color, description, name, slug #### GET /api/blog/categories/{id} - Auth: session (active organization required) - Path parameters: id #### PUT /api/blog/categories/{id} - Auth: session (active organization required) - Path parameters: id - Body fields: color, description, name #### DELETE /api/blog/categories/{id} - Auth: session (active organization required) - Path parameters: id #### POST /api/blog/categories/{id}/merge Fold one blog category into another. Free text drifts: a gallery ends up with "Studio Visits" and "Studio visit" and no way to tell the two apart on a list. Deleting one loses the categorisation of every post in it, so the only useful cleanup is a move — every post is repointed at the survivor, and the emptied category goes. The whole thing runs in one transaction. A half-done merge would leave posts filed under a category that no longer exists. - Auth: session (active organization required) - Path parameters: id - Body fields: into #### POST /api/blog/import - Auth: session (active organization required) - Body fields: post, update_existing #### GET /api/blog/posts - Auth: session (active organization required) - Query parameters: author_id, category_id, limit, page, search, status, tag_id #### POST /api/blog/posts - Auth: session (active organization required) - Body fields: allow_comments, category_ids, content, excerpt, featured_image, featured_image_alt, metadata, published_at, scheduled_for, seo_description, seo_keywords, seo_title, slug, status, tag_ids, title #### GET /api/blog/posts/{id} - Auth: session (active organization required) - Path parameters: id #### PUT /api/blog/posts/{id} - Auth: session (active organization required) - Path parameters: id - Body fields: allow_comments, category_ids, content, excerpt, featured_image, featured_image_alt, metadata, published_at, scheduled_for, seo_description, seo_keywords, seo_title, slug, status, tag_ids, title #### DELETE /api/blog/posts/{id} - Auth: session (active organization required) - Path parameters: id #### POST /api/blog/posts/bulk-categories File a selection of posts under categories, or take them out. Blogs get categorised in arrears. Posts arrive from an import or get written over a year with nobody minding the taxonomy, and then somebody sits down to sort out the forty that were never filed — which is exactly the job the "Uncategorised" filter and this endpoint are two halves of. The mode is not optional and is not guessed, for the reason the artwork bulk edit gives: "update the categories on these forty" has three genuinely different meanings, and picking wrong silently destroys work. `add` leaves what is there alone, `remove` takes one term off a mis-filed batch, and `replace` is the only one that discards — so it has to be asked for by name. - Auth: session (active organization required) #### POST /api/blog/posts/bulk-delete Delete a selection of posts, permanently. A hard delete, matching the single-post route rather than inventing an archive that only the bulk path would have — a post already has an ARCHIVED status for the case where somebody wants it out of the way but kept, and two different meanings of "gone" is worse than one. What goes with it: the category and tag links, which cascade. Those are labels rather than content, so nothing worth naming is lost sideways — but a published post is a live URL on the gallery's website, and that is what the dialog in front of this says out loud. Titles are read before the delete. Afterwards there is nothing left to name the posts in their own history, and an audit entry reading "Deleted" with no subject is worth very little to whoever finds it. - Auth: session (active organization required) #### GET /api/blog/tags - Auth: session (active organization required) #### POST /api/blog/tags - Auth: session (active organization required) - Body fields: name, slug #### POST /api/blog/upload POST /api/blog/upload — multipart form with a single `file`. Images land in `/blog/`, shared by the editor's inline image button and the featured-image picker so a post's pictures stay together. - Auth: public ### categories #### GET /api/categories - Auth: session (active organization required) - Query parameters: limit, page, search #### POST /api/categories Create a category, or hand back the one that already says this. The artwork form creates from a combobox, where "Sculpture" is typed by whoever is cataloguing rather than picked from a list — so the same name arriving twice is the normal case, not an error. Returning the existing row keeps that idempotent: two people cataloguing at once end up on one category instead of one of them seeing a failed save. - Auth: session (active organization required) - Body fields: description, name, slug #### GET /api/categories/{id} - Auth: session (active organization required) - Path parameters: id #### PUT /api/categories/{id} - Auth: session (active organization required) - Path parameters: id - Body fields: description, name #### DELETE /api/categories/{id} - Auth: session (active organization required) - Path parameters: id #### POST /api/categories/{id}/merge Fold one category into another. Free text drifts: a gallery ends up with "Works on Paper" and "Works On Paper" and no way to tell the two apart on a list. Deleting one loses the artworks' categorisation, so the only useful cleanup is a move — every artwork and every website selection is repointed at the survivor, and the emptied category goes. The whole thing runs in one transaction. A half-done merge would leave work filed under a category that no longer exists. - Auth: session (active organization required) - Path parameters: id - Body fields: into ### collections #### GET /api/collections GET /api/collections — the list. Each row carries its work count and the first few covers, because a collection is looked at rather than read: a list of names tells a gallery nothing about which grouping is which. - Auth: session (active organization required) - Query parameters: is_public, limit, page, search #### POST /api/collections POST /api/collections — create a collection, or hand back the one that already says this. Idempotent on slug for the reason the category create is: collections are typed rather than picked, here and in the importer, so the same name arriving twice is the normal case and not an error. - Auth: session (active organization required) #### GET /api/collections/{id} - Auth: session (active organization required) - Path parameters: id #### PATCH /api/collections/{id} PATCH /api/collections/:id — edit the collection's own fields. Only the fields a gallery actually edits are read out of the body. Spreading it into the update would let a request move a collection into another org by passing `org_id`, or hand it an arbitrary slug. - Auth: session (active organization required) - Path parameters: id #### DELETE /api/collections/{id} DELETE /api/collections/:id — unmake the grouping. The membership rows go with it by cascade; the works themselves are untouched. A collection is an opinion about inventory, not the inventory. - Auth: session (active organization required) - Path parameters: id #### PUT /api/collections/{id}/artworks PUT /api/collections/:id/artworks — set the membership. The whole list is sent rather than patched a work at a time, because the ordering is the content: a collection is a sequence someone arranged, and there is no meaningful way to add a work "in the middle" without restating the order around it. Position comes from the array, so the client never computes an index. The artwork importer deliberately does not come through here — it appends one work at a time via `appendArtworkToCollections`, because a row that wrote the whole membership would delete every row imported before it. - Auth: session (active organization required) - Path parameters: id #### POST /api/collections/import - Auth: session (active organization required) - Body fields: artwork_refs, collection ### consignments #### GET /api/consignments GET /api/consignments — agreements covering work in someone else's hands, or someone else's work in the gallery's. ?direction=IN|OUT, ?type=, ?status=, ?overdue=true, ?search=, ?artwork_id= - Auth: session (active organization required) - Query parameters: artwork_id, direction, limit, overdue, page, search, status, type #### POST /api/consignments POST /api/consignments — raise an agreement, optionally with its works. - Auth: session (active organization required) - Body fields: artist_id, commission_rate, counterparty_name, customer_id, direction, due_date, items, location_id, notes, start_date, status, terms, type #### GET /api/consignments/{id} - Auth: session (active organization required) - Path parameters: id #### PATCH /api/consignments/{id} - Auth: session (active organization required) - Path parameters: id - Body fields: artist_id, commission_rate, counterparty_name, customer_id, due_date, location_id, notes, start_date, status, terms, type #### DELETE /api/consignments/{id} DELETE /api/consignments/:id Only ever removes the paperwork. Works are referenced, never owned, by an agreement — deleting one must not take inventory with it, which is why the lines cascade but nothing touches the artworks themselves. - Auth: session (active organization required) - Path parameters: id #### POST /api/consignments/{id}/items POST /api/consignments/:id/items — add works to an existing agreement. - Auth: session (active organization required) - Path parameters: id - Body fields: agreed_price, artwork_id, commission_rate, currency, items, notes #### PATCH /api/consignments/{id}/items/{itemId} PATCH /api/consignments/:id/items/:itemId Marking a line returned or sold is the main thing anyone does on this screen, so it also carries the artwork status through — see `artworkStatusFor` for which transitions are automatic and, more importantly, which are not. - Auth: session (active organization required) - Path parameters: id, itemId - Body fields: agreed_price, commission_rate, currency, notes, status #### DELETE /api/consignments/{id}/items/{itemId} DELETE /api/consignments/:id/items/:itemId — take a work off an agreement. - Auth: session (active organization required) - Path parameters: id, itemId ### customers #### GET /api/customers - Auth: session (active organization required) - Query parameters: limit, page, search #### POST /api/customers - Auth: session (active organization required) #### GET /api/customers/{id} - Auth: session (active organization required) - Path parameters: id #### PUT /api/customers/{id} - Auth: session (active organization required) - Path parameters: id #### DELETE /api/customers/{id} - Auth: session (active organization required) - Path parameters: id #### POST /api/customers/import - Auth: session (active organization required) - Body fields: customer, update_existing ### dashboard #### GET /api/dashboard Aggregated numbers behind /dashboard. It exists so the overview loads from a single round trip instead of fanning out to every list endpoint and summing on the client — which would also only ever see the first page of each list. - Auth: session (active organization required) ### deals #### GET /api/deals GET /api/deals — the pipeline. ?stage=, ?open=true, ?customer_id=, ?search= - Auth: session (active organization required) - Query parameters: customer_id, open, search, stage #### POST /api/deals POST /api/deals — start working a sale. - Auth: session (active organization required) - Body fields: artwork_ids, currency, customer_id, expected_close, inquiry_id, notes, stage, value, viewing_room_id #### GET /api/deals/{id} - Auth: session (active organization required) - Path parameters: id #### PATCH /api/deals/{id} - Auth: session (active organization required) - Path parameters: id - Body fields: artwork_ids, currency, expected_close, lost_reason, notes, stage, value #### DELETE /api/deals/{id} - Auth: session (active organization required) - Path parameters: id #### POST /api/deals/{id}/offers POST /api/deals/:id/offers — put a price to the collector. Creating an offer moves the deal to OFFER unless it has already gone further, so the board reflects what has actually been done without anyone remembering to drag a card. With `send`, the offer is emailed to the collector as the dealer and only becomes SENT once the email has actually gone. If it does not go, the offer is kept as a draft and the response says why — an offer marked sent that nobody received is the failure this replaced. A mailbox that needs reconnecting answers 409 instead, with the draft saved. - Auth: session (active organization required) - Path parameters: id - Body fields: allow_system_fallback, amount, currency, expires_at, message, notes, send ### docs #### GET /api/docs/{slug} - Auth: public - Path parameters: slug ### editions #### GET /api/editions GET /api/editions — every run the gallery holds. - Auth: session (active organization required) - Query parameters: limit, page #### POST /api/editions POST /api/editions — turn a work into a run. The source work becomes impression 1 rather than a template that is copied, so everything already attached to it — inquiries, consignments, its place in a viewing room — stays attached to something a gallery can actually sell. - Auth: session (active organization required) - Body fields: artist_proofs, artwork_id, hors_commerce, notes, printer_proofs, size #### GET /api/editions/{id} - Auth: session (active organization required) - Path parameters: id #### PATCH /api/editions/{id} - Auth: session (active organization required) - Path parameters: id - Body fields: notes, size #### DELETE /api/editions/{id} DELETE /api/editions/:id — dissolve the run. Removes the grouping only. The impressions stay as individual works, because they are real objects the gallery still owns — deleting twenty-five records because somebody mis-set the edition size would be catastrophic and unrecoverable. - Auth: session (active organization required) - Path parameters: id ### emails #### GET /api/emails GET /api/emails?customer_id=… | ?deal_id=… — the correspondence on a collector's record, or on a deal. Sent and synced mail together, newest first, without bodies — those come from `GET /api/emails/:id` when a thread is opened, so a long history does not mean downloading every message to show a list. What each viewer gets is filtered by `src/lib/mailbox/visibility.ts`. - Auth: session (active organization required) #### GET /api/emails/{id} GET /api/emails/:id — one message with its body, if this viewer may read it. A message they may know about but not read (a colleague's "subject and date only") answers 403 rather than an empty body, so the screen can say why. - Auth: session (active organization required) - Path parameters: id ### entitlements #### GET /api/entitlements GET /api/entitlements — what this gallery may currently do. Readable by any member, unlike /api/billing which is owner-and-admin only. The dashboard shell asks this on every load to decide whether to show the app or the "choose a plan" gate, and a gallery assistant hitting a locked account needs to be told why just as much as the owner does — they simply cannot be the one to fix it. Deliberately small: no Stripe call, one indexed lookup and a count. - Auth: session (active organization required) #### GET /api/entitlements/artworks GET /api/entitlements/artworks — what makes up the artwork count. Behind the sidebar meter's detail dialog, and only asked for when it opens, so the per-navigation `/api/entitlements` check stays one count. Live works by status, plus the archived total — which sits outside the plan's count and is shown so a gallery near its limit can see what archiving would free. - Auth: session (active organization required) ### event-categories #### GET /api/event-categories - Auth: session (active organization required) - Query parameters: limit, page, search #### POST /api/event-categories Create a event category, or hand back the one that already says this. The event form creates from a combobox, where "Artist Talks" is typed by whoever is editing rather than picked from a fixed list — so the same name arriving twice is the normal case, not an error. Returning the existing row keeps that idempotent: two people writing at once end up on one category instead of one of them seeing a failed save. - Auth: session (active organization required) - Body fields: color, description, name, slug #### GET /api/event-categories/{id} - Auth: session (active organization required) - Path parameters: id #### PUT /api/event-categories/{id} - Auth: session (active organization required) - Path parameters: id - Body fields: color, description, name #### DELETE /api/event-categories/{id} - Auth: session (active organization required) - Path parameters: id #### POST /api/event-categories/{id}/merge Fold one event category into another. Free text drifts: a gallery ends up with "Artist Talks" and "Artist talk" and no way to tell the two apart on a list. Deleting one loses the categorisation of every event in it, so the only useful cleanup is a move — every event is repointed at the survivor, and the emptied category goes. The whole thing runs in one transaction. A half-done merge would leave events filed under a category that no longer exists. - Auth: session (active organization required) - Path parameters: id - Body fields: into ### events #### GET /api/events - Auth: session (active organization required) - Query parameters: category_id, limit, location_id, page, search, status #### POST /api/events - Auth: session (active organization required) #### GET /api/events/{id} - Auth: session (active organization required) - Path parameters: id #### PUT /api/events/{id} - Auth: session (active organization required) - Path parameters: id #### DELETE /api/events/{id} - Auth: session (active organization required) - Path parameters: id #### GET /api/events/{id}/rsvps GET /api/events/:id/rsvps — the guest list. Pass ?format=csv to download it instead of paging through it. - Auth: session (active organization required) - Path parameters: id - Query parameters: format, limit, page #### POST /api/events/{id}/rsvps POST /api/events/:id/rsvps — add a guest by hand, for the ones who phone in. Public submissions come through the public gallery endpoint instead. - Auth: session (active organization required) - Path parameters: id - Body fields: comments, email, name, party_size, source #### DELETE /api/events/{id}/rsvps/{rsvpId} DELETE /api/events/:id/rsvps/:rsvpId — drop a cancellation or a spam entry. - Auth: session (active organization required) - Path parameters: id, rsvpId #### POST /api/events/bulk-categories File a selection of events under categories, or take them out. Mirrors `blog/posts/bulk-categories.ts`, including its rule that the mode is never guessed: `add` leaves what is there alone, `remove` takes the named terms off, and `replace` is the only one that discards — so it has to be asked for by name. - Auth: session (active organization required) #### POST /api/events/bulk-delete Delete a selection of events, permanently. A hard delete, matching the single-event route. What goes with each event is more than labels: its RSVPs cascade, so a guest list goes too. The dialog in front of this says so, and each audit entry records how many RSVPs were lost alongside the title and slug read before the delete. - Auth: session (active organization required) #### POST /api/events/import - Auth: session (active organization required) - Body fields: event, update_existing #### POST /api/events/upload POST /api/events/upload — multipart form with a single `file`. Images land in `/events/`, shared by the description editor's inline image button and the featured-image picker. - Auth: public ### exhibitions #### GET /api/exhibitions - Auth: session (active organization required) - Query parameters: artist_id, limit, location_id, page, search #### POST /api/exhibitions - Auth: session (active organization required) #### GET /api/exhibitions/{id} - Auth: session (active organization required) - Path parameters: id #### PUT /api/exhibitions/{id} - Auth: session (active organization required) - Path parameters: id #### DELETE /api/exhibitions/{id} - Auth: session (active organization required) - Path parameters: id ### health #### GET /api/health - Auth: public ### inquiries #### GET /api/inquiries - Auth: session (active organization required) - Query parameters: limit, page, search, status #### POST /api/inquiries - Auth: session (active organization required) - Body fields: artwork_id, customer, customer_id, email, first_name, last_name, message, phone, source ### invitations #### GET /api/invitations/{id} Public read of a single invitation. The accept page has to render for someone who is not signed in yet, so this cannot sit behind require_auth. The invitation id is a 128-bit random token delivered by email — knowing it is the credential. Only fields the recipient already saw in that email are returned; nothing else about the organization is exposed. - Auth: public - Path parameters: id ### locations #### GET /api/locations - Auth: session (active organization required) - Query parameters: limit, page, search #### POST /api/locations - Auth: session (active organization required) #### GET /api/locations/{id} - Auth: session (active organization required) - Path parameters: id #### PUT /api/locations/{id} - Auth: session (active organization required) - Path parameters: id #### DELETE /api/locations/{id} Delete a location. Artwork, events, exhibitions, customers and invoices all reference a location optionally, so the database detaches them rather than deleting them. Passing `?reassign_to=` moves this location's artwork to another location first, so inventory doesn't silently fall out of every location. - Auth: session (active organization required) - Path parameters: id - Query parameters: reassign_to ### mailbox #### GET /api/mailbox GET /api/mailbox — my connected mailbox, if any, and what this gallery can connect. - Auth: public #### PATCH /api/mailbox PATCH /api/mailbox — change my mailbox's settings. Turning sync on for a mailbox connected only for sending needs a new consent, which this cannot give: it answers 409 `SCOPE_REQUIRED` with the URL to send the person through instead. - Auth: public #### DELETE /api/mailbox DELETE /api/mailbox — disconnect my mailbox. Deletes the mail synced from it. Records of mail GalleryCamp sent through it stay: they are the gallery's history of what went out. - Auth: public #### GET /api/mailbox/admin-consent/microsoft GET /api/mailbox/admin-consent/microsoft — the link a dealer sends their IT administrator when their organization won't let them approve apps themselves. Public on purpose: the person following it is in IT, not at the gallery, and has no GalleryCamp login. It only ever redirects to Microsoft with our own client id, so there is nothing here to protect. - Auth: public #### GET /api/mailbox/callback/{provider} GET /api/mailbox/callback/:provider — where Google and Microsoft send people back after the consent screen. Also where Microsoft returns an IT administrator who approved GalleryCamp for their organization. That visitor is usually not a GalleryCamp user at all, so that branch answers with a plain page and needs no session. - Auth: session (active organization required) - Path parameters: provider #### GET /api/mailbox/connect/{provider} GET /api/mailbox/connect/:provider — send the signed-in member to Google or Microsoft to connect their mailbox. Navigated to, not fetched: the browser follows the redirect to the consent screen. `?intent=sync` asks for the read scope too, and is what the Sync switch sends people through when the mailbox was connected for sending only. Every refusal goes back to the settings page with a code rather than a JSON error, since there is a person looking at whatever this returns. - Auth: session (active organization required) - Path parameters: provider - Query parameters: intent #### GET /api/mailbox/sender GET /api/mailbox/sender — who mail I send from here right now would come from. Asked by the offer, viewing room and invoice dialogs before a send, so the "Sends from" line is the same answer the send will reach — including that a mailbox needs reconnecting, before the button is pressed rather than after. - Auth: session (active organization required) #### POST /api/mailbox/sync POST /api/mailbox/sync — read my mailbox again soon, rather than on the usual few-minute cadence. Marks it due; the sync worker picks it up on its next tick, within a minute. Not run here in the request: a first backfill can take longer than anyone should hold a request open for. - Auth: session (active organization required) #### GET /api/mailbox/team GET /api/mailbox/team — who in the gallery has connected a mailbox. Owners and admins only, and status only: provider, address, whether it is working, whether it syncs. Never anything read out of it. An owner needs to know that the director's Outlook stopped working before a week of offers goes out from the system address; they do not need to read the director's mail to know that. - Auth: session (active organization required) ### metadata-keys #### GET /api/metadata-keys - Auth: session (active organization required) - Query parameters: key, type ### notes #### GET /api/notes GET /api/notes?target_type=artwork&target_id= Newest first — notes are read as a running log, and the most recent entry is the one that matters when you open a record. - Auth: session (active organization required) - Query parameters: target_id, target_type #### POST /api/notes POST /api/notes Two shapes, because a note may arrive with files: JSON { content, target_type, target_id } multipart content, target_type, target_id, files (repeated) A note needs *something* — text or at least one file. Dropping an image with no caption is a normal way to say "here's the photo", so files alone are enough; the JSON path still requires text, having no other way to carry meaning. - Auth: session (active organization required) - Body fields: content, target_id, target_type #### PUT /api/notes/{id} PUT /api/notes/:id { content } - Auth: session (active organization required) - Path parameters: id - Body fields: content #### DELETE /api/notes/{id} DELETE /api/notes/:id - Auth: session (active organization required) - Path parameters: id ### notifications #### GET /api/notifications/preferences The signed-in member's notification settings for their active organization. - Auth: session (active organization required) #### PUT /api/notifications/preferences Update one preference for the signed-in member. A member can only ever change their own settings — the user id comes from the session, never from the request body. - Auth: session (active organization required) - Body fields: enabled, type ### offers #### PATCH /api/offers/{id} PATCH /api/offers/:id — move an offer along. Accepting is the interesting one: it raises a draft order for the works at the price that was actually offered, which is the whole point of tracking offers rather than remembering them. `send` and `revise` email the collector as the dealer. Edits in the same request are saved either way; the status only moves to SENT once the email has gone, and `delivery` in the response says what happened. - Auth: session (active organization required) - Path parameters: id - Body fields: action, allow_system_fallback, amount, currency, expires_at, message, notes #### DELETE /api/offers/{id} DELETE /api/offers/:id — remove a draft that was never sent. - Auth: session (active organization required) - Path parameters: id ### orders #### GET /api/orders - Auth: session (active organization required) - Query parameters: limit, page, search, source, status #### POST /api/orders - Auth: session (active organization required) #### GET /api/orders/{id} - Auth: session (active organization required) - Path parameters: id #### PUT /api/orders/{id} - Auth: session (active organization required) - Path parameters: id #### DELETE /api/orders/{id} - Auth: session (active organization required) - Path parameters: id #### POST /api/orders/{id}/issue POST /api/orders/:id/issue — finalise a draft without sending it anywhere. A draft is the gallery's working copy: nothing is owed and the works on it are not held. Three things end that, and until now only two of them existed — emailing the collector (`send`) and taking a payment. The third is handing the invoice over on paper, which is how a great many sales in a gallery actually close: the collector is standing there. This is `send` with the sending removed. The same two changes happen, because they are what "no longer a draft" means: the order becomes a receivable and its works move to RESERVED so nobody else is sold them. What it does not do is require Stripe, mint a payment link or email anybody — a gallery printing an invoice for a collector in the room should not need a card processor configured to do it. Already-issued orders are not an error. Two tabs, or a second click while the first was in flight, should leave the order issued rather than showing the gallery a failure for something that has already happened. - Auth: session (active organization required) - Path parameters: id #### POST /api/orders/{id}/payment-link POST /api/orders/:id/payment-link — mint the collector's payment page. The token is created on demand and then kept, so sending the link twice does not invalidate the copy already in the collector's inbox. - Auth: session (active organization required) - Path parameters: id #### DELETE /api/orders/{id}/payment-link DELETE /api/orders/:id/payment-link — revoke the link. Dropping the token closes the page for anyone holding the address, which is the remedy when an invoice is forwarded to the wrong person. - Auth: session (active organization required) - Path parameters: id #### GET /api/orders/{id}/payments GET /api/orders/:id/payments — every payment against an order. Returned newest first with the order's running balance alongside, because the two are never useful apart: a list of payments without what is still owed makes the reader do the arithmetic. - Auth: session (active organization required) - Path parameters: id #### POST /api/orders/{id}/payments POST /api/orders/:id/payments — apply a payment to an order. Two shapes of thing behind one route, split on the method. Cash, cheque, wire and the rest are recorded as already cleared: somebody is physically holding the money and saying so. Card and ACH create a Stripe Checkout Session on the gallery's own connected account and return its client secret for the dialog to mount — those stay PENDING until the webhook says otherwise, so no amount enters the order's balance on the strength of a form being submitted. - Auth: session (active organization required) - Path parameters: id #### DELETE /api/orders/{id}/payments/{paymentId} DELETE /api/orders/:id/payments/:paymentId — remove a payment. Only for payments the gallery entered itself, and only ever a correction: a cheque keyed in twice, an amount typed wrong. A settled card or bank payment cannot be deleted here at all — the money exists whatever this table says, and the only honest way to reverse it is a refund in Stripe. Deleting the row would leave the order looking unpaid while the collector's statement disagrees. - Auth: session (active organization required) - Path parameters: id, paymentId #### POST /api/orders/{id}/send POST /api/orders/:id/send — finalise a draft and email the collector. A draft is the gallery's working copy: nothing is owed and the works on it are not held. Sending is the moment it becomes a receivable, so the two happen together — the status moves to UNPAID, the works move to RESERVED, and the collector gets the link that lets them pay it. The order is only converted once the email is away. A draft that silently became a receivable while the collector heard nothing is the worst of the outcomes here: the gallery would believe it was waiting on a payment nobody had been asked for. Sent as the person pressing Send — through their own mailbox if they have connected one — or, for an API call with nobody behind it, as the order's sales rep. A mailbox that needs reconnecting answers 409 unless the body says `allow_system_fallback: true`. - Auth: session (active organization required) - Path parameters: id #### POST /api/orders/tax-preview Price an order's tax without saving anything. The order form shows the tax as the gallery builds the order, and it has to be the same figure the create route will charge. Asking the server rather than re-implementing the arithmetic in the browser keeps one copy of it — the client does not know the location defaults, the collector's exemption or the works' acquisition costs, and guessing at any of them is how a preview and an invoice come to disagree. A margin-scheme line with no acquisition cost comes back as `ok: false` with the failures, not as a 422, because for a preview that is information to show rather than a request to reject. - Auth: session (active organization required) - Body fields: items ### organizations #### GET /api/organizations - Auth: session #### POST /api/organizations/create - Auth: session - Body fields: name, slug #### GET /api/organizations/current - Auth: session (active organization required) #### PATCH /api/organizations/current - Auth: session (active organization required) - Body fields: brand_color, contact_email, logo, name, slug, website_url #### GET /api/organizations/demo - Auth: session #### POST /api/organizations/demo - Auth: session #### GET /api/organizations/export Downloads everything the active organization holds as a ZIP. Deliberately outside the billing gate: the terms promise a gallery its data after a subscription lapses, so a read-only account can still take it. Owner-only, because the file holds every collector and every price — the same reach as deleting the organization, and the same person. - Auth: session (active organization required) #### POST /api/organizations/logo POST /api/organizations/logo — the gallery's mark. The gallery's, not a website's. This image goes on printed invoices, price lists, the collector's payment page and viewing rooms — none of which is a website, and all of which used to read the logo off whichever site was flagged primary. A gallery that ran no site had no letterhead, and changing which site was primary changed the letterhead as a side effect. Modelled on the per-site upload it replaces, including the ordering: the new object is recorded before the old one is removed, so a failed cleanup costs an orphaned file rather than a broken image. - Auth: session (active organization required) #### POST /api/organizations/purge Permanently destroys an organization: its billing, its storage objects, then every row that hangs off it, then the organization itself. Ordering matters. Billing goes first because it is the step most likely to be refused, and an organization deleted while Stripe keeps renewing it is worse than one not deleted at all. Storage goes next because the image rows are what tell us which objects to delete — once the cascade runs, that mapping is gone and any surviving object is unreachable garbage. A storage failure therefore aborts before any row is touched, leaving the org intact and retryable. - Auth: session (active organization required) - Body fields: confirmation #### POST /api/organizations/set - Auth: session - Body fields: organizationId ### payouts #### GET /api/payouts GET /api/payouts — statements raised for artists. ?artist_id=, ?status=, ?search= - Auth: session (active organization required) - Query parameters: artist_id, limit, page, search, status #### POST /api/payouts POST /api/payouts — raise a statement from an artist's unsettled sales. The split for each sale is computed here and written onto the line, never recomputed later: rates change, and a statement that moved after it was sent would stop matching the money. - Auth: session (active organization required) - Body fields: adjustment_note, adjustments, artist_id, notes, order_item_ids, period_end, period_start #### GET /api/payouts/{id} - Auth: session (active organization required) - Path parameters: id #### PATCH /api/payouts/{id} - Auth: session (active organization required) - Path parameters: id - Body fields: adjustment_note, adjustments, notes, payment_method, payment_reference, period_end, period_start, status #### DELETE /api/payouts/{id} DELETE /api/payouts/:id Only ever a draft. Once a statement has been approved or paid it is part of the financial record, and the way to undo it is to void it. - Auth: session (active organization required) - Path parameters: id #### GET /api/payouts/unsettled GET /api/payouts/unsettled — what is owed but not yet settled. With ?artist_id= it returns the sales themselves, which is what the "raise a statement" screen lists. Without one it returns a total per artist, which is the "who am I behind with?" view. ?from= and ?to= bound the sale dates. - Auth: session (active organization required) - Query parameters: artist_id, from, to ### print #### GET /api/print/artworks - Auth: session (active organization required) - Query parameters: consignment, exhibition, ids, room #### GET /api/print/invoice GET /api/print/invoice?order= — the billing document for a sale. An invoice is a rendering of an order rather than a record of its own: the order is what happened, the invoice is the piece of paper the collector is handed. That keeps one set of totals rather than two that can disagree. - Auth: session (active organization required) - Query parameters: order ### profiles #### GET /api/profiles/artist - Auth: session (active organization required) #### POST /api/profiles/artist - Auth: session (active organization required) - Body fields: address, artist_statement, bio, city, country, facebook, instagram, phone, postal_code, state, twitter, website #### PATCH /api/profiles/artist - Auth: session (active organization required) - Body fields: address, artist_statement, bio, city, country, facebook, instagram, phone, postal_code, state, twitter, website #### GET /api/profiles/gallery - Auth: session (active organization required) #### POST /api/profiles/gallery - Auth: session (active organization required) - Body fields: address, city, commission_rate, country, description, facebook, gallery_name, instagram, phone, postal_code, state, tax_id, twitter, website #### PATCH /api/profiles/gallery - Auth: session (active organization required) ### public #### GET /api/public/orders/{token} GET /api/public/orders/:token — what the collector sees before paying. Unauthenticated by design: the token is the credential. It therefore returns only what belongs on the invoice the collector already holds — no internal notes, no cost prices, no other records. - Auth: public - Path parameters: token #### POST /api/public/orders/{token}/checkout POST /api/public/orders/:token/checkout — start a card payment. The charge is created *on the gallery's connected account* (`stripeAccount` below), which is what makes this a payment to the gallery rather than to GalleryCamp. Nothing settles to a GalleryCamp balance at any point. - Auth: public - Path parameters: token #### GET /api/public/rooms/{token} GET /api/public/rooms/:token — the room behind a link. Unauthenticated by design: the token is the credential. A room that has not opened, or was never published, is deliberately indistinguishable from one that does not exist — knowing a gallery is preparing something is itself information a stray link should not leak. Nothing is recorded here. Opens arrive from the page as an event, which is what keeps a corporate mail scanner fetching every link in an inbound message from reading as a collector opening it. - Auth: public - Path parameters: token - Query parameters: passcode #### POST /api/public/rooms/{token}/events POST /api/public/rooms/:token/events — what the collector did. The target of the page's `sendBeacon`, which means two things about how it answers: the browser discards the response, so the body is a formality, and the call must never be able to hold up the page it came from. It also means a client that blocks beacons — and some collectors run one — simply produces no rows. That is a supported outcome, not a failure: the room renders identically and the gallery sees "opened, no detail" instead of a broken page. - Auth: public - Path parameters: token #### POST /api/public/rooms/{token}/inquiries POST /api/public/rooms/:token/inquiries — "tell me more about this one". The inquiry lands in the same inbox as one from the public site, already attributed to the room, the work and the person, because the link said who they were. That attribution is the entire reason the follow-up is worth anything: the gallery knows what prompted it. - Auth: public - Path parameters: token - Body fields: artwork_id, email, message, name, phone #### GET /api/public/sites/{site_id} GET /api/public/sites/:site_id — the public face of one site. Unauthenticated: the id is the whole address, which is the same bargain the preview makes — a cuid is unguessable, and an unpublished site resolves to nothing here whoever asks. The payload is built by presentSiteContext(), shared with /api/public/sites/resolve so that a site reached by its own domain and the same site reached by its id cannot describe themselves differently. - Auth: public - Path parameters: site_id #### GET /api/public/sites/{site_id}/artists GET /api/public/sites/:site_id/artists — the artists this site shows. An artist earns their place by having work on the site, not by existing in the gallery's records. That single rule holds across all three selection modes without special-casing any of them: a fair microsite showing twelve works lists only the artists behind those twelve, and an artist whose entire output is archived quietly drops off rather than leading to an empty page. `category_id` narrows that set rather than competing with it — see the note on the clause below. - Auth: public - Path parameters: site_id #### GET /api/public/sites/{site_id}/artists/{artistId} GET /api/public/sites/:site_id/artists/:artistId — one artist, and the work of theirs this site carries. An artist the site does not publish is a 404 rather than an empty profile: the gallery's roster is not public information, only what it chose to show. - Auth: public - Path parameters: site_id, artistId #### GET /api/public/sites/{site_id}/artwork-status GET /api/public/sites/:site_id/artwork-status?ids=… — what has changed. A published gallery site is a static build: the availability printed on it was true when it was generated. This is what the page calls to correct itself, so a work sold this morning stops being offered this afternoon without waiting for a rebuild. It returns the two things that go stale and nothing else. Keeping it that narrow is the point — it is called by every visitor on every page, so it has to stay a cheap, cacheable read rather than a second way to fetch artworks. - Auth: public - Path parameters: site_id #### GET /api/public/sites/{site_id}/artworks List the works a site publishes. Paged (`page`, `limit` up to 100) and filterable by `category_id`, `collection_id` (id or slug), `status`, `min_price`, `max_price` and `search`; sorted by `sort_by` (created_at, title, price, year) and `sort_order`. Editions collapse to one row per run unless `?group=none`. Each work carries `purchasable`, true when it can go in an online-store cart. - Auth: public - Path parameters: site_id #### GET /api/public/sites/{site_id}/artworks/{artworkId} GET /api/public/sites/:site_id/artworks/:artworkId The single-artwork read a headless site needs for its artwork pages. It answers under exactly the same visibility rules as the list beside it: the site must be published with its shop on, and the work must fall inside what that site publishes. A piece reachable by id but excluded from the site's selection would defeat the point of having a selection at all. - Auth: public - Path parameters: site_id, artworkId #### POST /api/public/sites/{site_id}/artworks/{artworkId}/inquiries - Auth: public - Path parameters: site_id, artworkId - Body fields: email, first_name, last_name, message, phone, source #### GET /api/public/sites/{site_id}/blog GET /api/public/sites/:site_id/blog — the site's journal. `published_at` is checked against the clock as well as the status, because a scheduled post that has been promoted to PUBLISHED early would otherwise appear before its date. - Auth: public - Path parameters: site_id #### GET /api/public/sites/{site_id}/blog/{slug} GET /api/public/sites/:site_id/blog/:slug — one journal post, in full. Unlike the listing, this one carries `content`. The view counter is left alone: a server-rendered site would increment it on every crawler visit, and a number that counts robots is worse than no number. - Auth: public - Path parameters: site_id, slug #### GET /api/public/sites/{site_id}/categories GET /api/public/sites/:site_id/categories — the facets worth offering. Only categories that would actually return something: a filter bar whose options lead to empty grids is worse than a shorter filter bar. - Auth: public - Path parameters: site_id #### GET /api/public/sites/{site_id}/collections GET /api/public/sites/:site_id/collections — the sets this site publishes. Two independent gates, and both matter. `is_public` is the gallery's own decision, and it is off by default because a collection is a working tool first — "Basel shortlist", "damaged, needs assessment" — and the ones meant for visitors are the exception. The site's selection is the second: a collection is owned by the gallery rather than by any one site, so a fair microsite must not advertise a set whose works it does not carry. A collection that passes the first gate and fails the second is absent rather than empty. An empty set on a website is a dead link with a title. - Auth: public - Path parameters: site_id #### GET /api/public/sites/{site_id}/collections/{collectionId} GET /api/public/sites/:site_id/collections/:collectionId — one set and the works in it, in the order the gallery arranged them. Addressed by id or by slug; see `resolvePublicCollection`. The works are narrowed to what this site publishes rather than shown whole, for the reason the exhibition checklist is: a curated site listing the set's other forty works would undo the curation in a single click. Not paginated. A collection holds at most five hundred works and each one comes back as a card with a single image, which is a payload a page can take in one call. A site that wants to page a large set should use `/artworks?collection_id=…`, which carries the whole filter and sort vocabulary. - Auth: public - Path parameters: site_id, collectionId #### GET /api/public/sites/{site_id}/events GET /api/public/sites/:site_id/events — the public calendar. - Auth: public - Path parameters: site_id #### POST /api/public/sites/{site_id}/events/{eventId}/rsvps POST /api/public/sites/:site_id/events/:eventId/rsvps The endpoint an integrating website posts its RSVP form to. Open to any origin, but only for events whose gallery has RSVPs switched on and whose deadline hasn't passed. - Auth: public - Path parameters: site_id, eventId - Body fields: comments, email, name, party_size, source #### GET /api/public/sites/{site_id}/exhibitions GET /api/public/sites/:site_id/exhibitions — the site's programme. A show appears if the site publishes at least one work in it, so a fair microsite does not advertise the gallery's whole history. Shows with nothing attached yet are the exception: on a site that carries everything they are legitimate content — a press release written before the checklist exists — but on a narrowed site there is nothing to justify their inclusion. - Auth: public - Path parameters: site_id #### GET /api/public/sites/{site_id}/exhibitions/{exhibitionId} GET /api/public/sites/:site_id/exhibitions/:exhibitionId — one show and its checklist, narrowed to what this site publishes. The checklist is filtered rather than shown whole: a curated site listing a show's other forty works would undo the curation in a single click. - Auth: public - Path parameters: site_id, exhibitionId #### GET /api/public/sites/{site_id}/skill.md GET /api/public/sites/:site_id/skill.md — the briefing for whatever is building this gallery's website. Public and unauthenticated, like the rest of this tree, because that is what makes it useful: a gallerist pastes the URL into Claude Code or Cursor and the assistant fetches it itself. Requiring a key would mean downloading a file and re-uploading it, which is the friction this exists to remove. It discloses nothing a caller could not learn by reading the endpoints it documents. Served as markdown rather than JSON so a browser shows it, an assistant reads it as prose, and `curl -O` produces a file that drops straight into a repo. - Auth: public - Path parameters: site_id #### GET /api/public/sites/{site_id}/store/artworks List the works a site's online store sells. The site's own works, narrowed to those the gallery put up for sale online with a price. Takes the same query parameters as `/artworks` — `page`, `limit`, `category_id`, `collection_id`, `min_price`, `max_price`, `search`, `sort_by`, `sort_order`, `group` — and returns the same shape. Only works that can be bought right now by default. `?include_unavailable=1` also lists the reserved and sold ones, each with `purchasable: false`, for a store that shows what has gone. An edition collapses to its lowest-numbered impression still for sale, which is the one to put in the cart. - Auth: public - Path parameters: site_id - Query parameters: include_unavailable #### POST /api/public/sites/{site_id}/store/carts Start an online-store cart, optionally with works already in it. Returns 201 with the cart, including its `token`. The website keeps the token — in localStorage, typically — and uses it for every later cart call. A cart lapses 20 minutes after it was last read or changed; after that its calls answer 404 `CART_EXPIRED` and the site should start a new one. - Auth: public - Path parameters: site_id - Body fields: artwork_ids #### GET /api/public/sites/{site_id}/store/carts/{token} Read an online-store cart. Each work comes back with `available` and, when it is not, an `unavailable_reason` (SOLD, RESERVED or WITHDRAWN) — another collector may have bought it since it was added. `subtotal`, `tax` and `total` cover the available works only; the tax is an estimate until checkout knows who is buying. `checkout_ready` says whether checkout would go through. Reading the cart counts as using it: its 20-minute idle clock starts again. - Auth: public - Path parameters: site_id, token #### POST /api/public/sites/{site_id}/store/carts/{token}/checkout Check out an online-store cart. Creates an order for the works in the cart, reserves them for this collector, emails the collector their order, and returns 201 with `{ order_number, pay_url, expires_at, total, currency }`. The website then sends the collector to `pay_url`, the gallery's invoice page, to pay by card. The works are held until `expires_at`, an hour from now; an order still unpaid then is cancelled and the works go back on sale. `return_url`, if given, must be an https address on this same website — the invoice page links back to it once the collector has paid. Refused with 409 `NO_LONGER_AVAILABLE` when any work in the cart has gone (remove it and try again), 409 `STORE_UNAVAILABLE` when the gallery cannot take card payments, and 404 `CART_EXPIRED` for a lapsed cart. - Auth: public - Path parameters: site_id, token - Body fields: city, country, email, first_name, last_name, line1, line2, notes, phone, postal_code, region, return_url, shipping_address #### POST /api/public/sites/{site_id}/store/carts/{token}/items Add a work to an online-store cart, and return the cart. Refused with 404 `NOT_FOR_SALE` for a work the store does not sell, 409 `NOT_AVAILABLE` for one already reserved or sold, 409 `CURRENCY_MISMATCH` for one priced in a different currency from what is already in the cart, and 409 `CART_FULL` past 20 works. Adding a work that is already in the cart changes nothing. - Auth: public - Path parameters: site_id, token - Body fields: artwork_id #### DELETE /api/public/sites/{site_id}/store/carts/{token}/items/{artworkId} Take a work out of an online-store cart, and return the cart. Removing a work that is not in the cart changes nothing, so a retried request is harmless. - Auth: public - Path parameters: site_id, token, artworkId #### GET /api/public/sites/resolve GET /api/public/sites/resolve?host=… — which site answers to this hostname. The rest of the public API is addressed by website id, which the default address carries in its first label but a gallery's own domain does not. The site runtime therefore boots by asking this route once per hostname; the id it returns is the key for every subsequent call. The full site context comes back with it rather than requiring a second call, because the runtime needs the branding and the gallery's details before it can render anything at all. - Auth: public - Query parameters: host ### reports #### GET /api/reports/analytics GET /api/reports/analytics — engagement across viewing rooms. This reports on the audience the app can actually observe: named recipients opening rooms they were sent. It is not website analytics — nothing tracks visitors to the public gallery site — so there are no page URLs and no geography here, and every figure below comes from viewing-room telemetry that the room's own `track_engagement` switch governs. Pass ?format=csv to download the visit log. - Auth: session (active organization required) - Query parameters: format #### GET /api/reports/insurance - Auth: session (active organization required) - Query parameters: format #### GET /api/reports/inventory GET /api/reports/inventory — what the gallery holds, what it is worth, and what has been sitting too long. Scoped to live works: archiving is orthogonal to status, so an archived piece still carries one and would otherwise be counted as stock the gallery can sell. The date range only bounds the activity feed — a stock position is always "as of now", never "as of the last 30 days". Pass ?format=csv to download the full inventory. - Auth: session (active organization required) - Query parameters: format #### GET /api/reports/sales GET /api/reports/sales — revenue, what sold, and who bought it. A sale here is a PAID order. Orders are the record that carries payment method and paid date, and /api/dashboard already treats them as the revenue source, so the reports agree with the overview instead of quietly disagreeing by whatever sits in draft invoices. Pass ?format=csv to download the underlying transactions. - Auth: session (active organization required) - Query parameters: format ### rooms #### GET /api/rooms GET /api/rooms — the gallery's viewing rooms. Each row carries how many people were sent a link and how many of them have opened it, because that ratio is the first thing anyone wants from this list and it is one query away. - Auth: session (active organization required) - Query parameters: limit, page, search, status #### POST /api/rooms POST /api/rooms — make a room. A preview link is minted with the room rather than on demand, so the gallery can always see their own work in progress exactly as a collector eventually will. It is flagged, produces no statistics, and is the only link that reaches a room still in draft. - Auth: session (active organization required) #### GET /api/rooms/{id} - Auth: session (active organization required) - Path parameters: id #### PATCH /api/rooms/{id} - Auth: session (active organization required) - Path parameters: id #### DELETE /api/rooms/{id} DELETE /api/rooms/:id Takes the recipients, visits, events and rollups with it — the cascade is in the schema. A gallery deleting a room is saying the presentation never happened, and half-kept telemetry about who looked at it would be worse than none. - Auth: session (active organization required) - Path parameters: id #### GET /api/rooms/{id}/activity GET /api/rooms/:id/activity — what the room produced. Reads the rollup rather than the event stream. Everything here is per work per person, which is exactly the shape `viewing_room_artwork_stats` is kept in, so the screen costs one indexed read instead of an aggregation over telemetry. Pass ?recipient_id= to narrow it to one collector. - Auth: session (active organization required) - Path parameters: id - Query parameters: recipient_id #### PUT /api/rooms/{id}/artworks PUT /api/rooms/:id/artworks — set the checklist. The whole list is sent rather than patched a work at a time, because the ordering is the content: a room is a sequence someone arranged, and there is no meaningful way to add a work "in the middle" without restating the order around it. Position comes from the array, so the client never computes an index. - Auth: session (active organization required) - Path parameters: id #### GET /api/rooms/{id}/recipients GET /api/rooms/:id/recipients — who was sent this room, and what came back. The preview link is excluded. It is a real recipient row so the room has a way in before it is published, but it is the gallery looking at their own work and does not belong in a list about collectors. - Auth: session (active organization required) - Path parameters: id #### POST /api/rooms/{id}/recipients POST /api/rooms/:id/recipients — give one person their own way in. The token is returned here and nowhere else. From this response on, only its digest exists, so a link that is lost has to be reissued rather than looked up — which is the same promise made to the collector holding it. Pass `send: true` to mail it at the same time. Mail failing does not fail the request: the link exists either way, and the gallery can copy it. - Auth: session (active organization required) - Path parameters: id #### PATCH /api/rooms/{id}/recipients/{recipientId} PATCH /api/rooms/:id/recipients/:recipientId The four things a gallery does to a link after making it: take it back, hand out a fresh one, send it again, or change the note it carries. Reissuing returns the new token — the only other moment one exists in the clear. - Auth: session (active organization required) - Path parameters: id, recipientId - Body fields: allow_system_fallback, expires_at, private_note, reissue, revoked, send #### DELETE /api/rooms/{id}/recipients/{recipientId} DELETE /api/rooms/:id/recipients/:recipientId Removes the person and everything recorded about their visit. Revoking is usually what a gallery means — it closes the door and keeps the history — so this is the deliberate, destructive one. - Auth: session (active organization required) - Path parameters: id, recipientId ### search #### GET /api/search - Auth: session (active organization required) - Query parameters: q ### settings #### GET /api/settings/payments GET /api/settings/payments — whether this gallery can take card payments. Refreshes from Stripe when an account exists, because onboarding finishes on Stripe's side and the webhook may not have arrived by the time the gallery lands back on this page. - Auth: session (active organization required) #### POST /api/settings/payments POST /api/settings/payments — start or resume Stripe onboarding. Returns a single-use link. Stripe expires these quickly, so the button always mints a fresh one rather than storing the URL. - Auth: session (active organization required) #### DELETE /api/settings/payments DELETE /api/settings/payments — stop taking payments through this gallery's account. Only forgets the connection locally. Deleting the account itself is Stripe's to offer: it holds the gallery's payment history, and a mis-click here must not destroy their records. - Auth: session (active organization required) #### GET /api/settings/website GET /api/settings/website — the gallery's public site configuration. Creates the row on first read. Nine other endpoints look these settings up to serve public pages and printed documents, so a gallery that has never opened this screen still needs to have one. - Auth: session (active organization required) #### PUT /api/settings/website PUT /api/settings/website — save the configuration. - Auth: session (active organization required) - Body fields: about_text, contact_email, domain, enable_blog, enable_events, enable_shop, facebook, favicon, hero_image, hero_subtitle, hero_title, instagram, logo, primary_color, secondary_color, seo_description, seo_keywords, seo_title, twitter #### POST /api/settings/website/upload/{type} POST /api/settings/website/upload/:type — logo, favicon or hero image. The file arrives under a field named after the kind rather than a generic "file", which is what the settings form sends. - Auth: session (active organization required) - Path parameters: type ### tax-rates #### GET /api/tax-rates - Auth: session (active organization required) - Query parameters: include_archived #### POST /api/tax-rates - Auth: session (active organization required) - Body fields: jurisdiction, name, rate #### GET /api/tax-rates/{id} - Auth: session (active organization required) - Path parameters: id #### PUT /api/tax-rates/{id} - Auth: session (active organization required) - Path parameters: id #### DELETE /api/tax-rates/{id} Delete a rate outright, or archive it if anything still points at it. The rate's own foreign keys are `SET NULL`, so deleting one would not damage an order — the line keeps its copy of the name and percentage and the invoice still reads correctly. What it would lose is the link back, and with it the ability to ask "what else did we charge NY Sales Tax on". So a rate with history is archived instead, and the caller is told which happened rather than being left to guess why the row is still there. - Auth: session (active organization required) - Path parameters: id ### team #### GET /api/team/invitations - Auth: session (active organization required) #### DELETE /api/team/invitations/{id} - Auth: session (active organization required) - Path parameters: id #### POST /api/team/invitations/{id}/resend - Auth: session (active organization required) - Path parameters: id #### POST /api/team/invite - Auth: session (active organization required) - Body fields: email, role #### GET /api/team/members - Auth: session (active organization required) #### PUT /api/team/members/{id} - Auth: session (active organization required) - Path parameters: id - Body fields: role, status #### DELETE /api/team/members/{id} - Auth: session (active organization required) - Path parameters: id ### users #### GET /api/users/me - Auth: session #### PATCH /api/users/me - Auth: session (active organization required) - Body fields: email, image, name #### DELETE /api/users/me Deletes the signed-in person's own account. Refused while they are the only owner of an organization, because deleting them would leave a workspace — and its subscription — that nobody can administer or close. They hand ownership over or delete the organization first; both are in organization settings. Organization records they touched stay with the organization: sales rep, deal owner and payment "recorded by" are cleared by the schema, and blog posts move to an owner, since a post needs an author. The audit log keeps the name it recorded, which is what an audit log is for. - Auth: session ### webhooks #### POST /api/webhooks/stripe POST /api/webhooks/stripe — Stripe telling us what actually happened. This is the only place an order is marked paid. The browser returning to `?paid=1` proves a collector reached the success page, not that money moved — a redirect can be typed by hand, and a genuine payment can complete after the collector has closed the tab. Set STRIPE_WEBHOOK_SECRET and point a Connect endpoint here. - Auth: public ### websites #### GET /api/websites GET /api/websites — every public site the gallery runs. Creates the first one on demand, the same way the single-site settings did, so an organization that has never opened this screen still has a working site behind its viewing rooms and printed documents. - Auth: session (active organization required) #### POST /api/websites POST /api/websites — add another site. - Auth: session (active organization required) - Body fields: is_published, name, selection_mode #### GET /api/websites/{id} - Auth: session (active organization required) - Path parameters: id #### PUT /api/websites/{id} - Auth: session (active organization required) - Path parameters: id - Body fields: about_text, artist_ids, artwork_ids, category_ids, contact_email, domain, enable_blog, enable_events, enable_shop, facebook, favicon, hero_image, hero_subtitle, hero_title, instagram, is_published, logo, name, primary_color, public_metadata_keys, secondary_color, selection_mode, twitter #### DELETE /api/websites/{id} DELETE /api/websites/:id Refuses the last site and refuses the primary. A gallery with no site has nowhere for its viewing rooms and documents to draw branding from, and deleting the primary specifically would leave that choice unmade. - Auth: session (active organization required) - Path parameters: id #### POST /api/websites/{id}/builder/chat POST /api/websites/:id/builder/chat — say something to the builder. Returns what it said back, what it changed, and the conversation so far. A turn that changed nothing is a perfectly good turn: "what would you suggest?" deserves an answer, not an edit. - Auth: session (active organization required) - Path parameters: id - Body fields: images, kind, message, path, provider #### GET /api/websites/{id}/builder/conversation GET /api/websites/:id/builder/conversation — everything said about this site. The whole of it, oldest first. The screen shows all of it; only what the model is given is trimmed, and that happens on the way into a turn rather than here. - Auth: public - Path parameters: id #### DELETE /api/websites/{id}/builder/conversation DELETE /api/websites/:id/builder/conversation — start a fresh conversation. The site is untouched. This clears what was said, not what was done: every version is still in the history and the site still looks exactly as it did. - Auth: public - Path parameters: id #### GET /api/websites/{id}/builder/element GET /api/websites/:id/builder/element?loc=… — what this element really is. The page cannot answer this. `

Our gallery

` and `

{gallery.name} has not written an introduction yet.

` are the same run of text to a browser, and only one of them is prose somebody should be handed a text box for. So the panel asks the file, and offers exactly what the file will accept — instead of offering a box, taking a sentence, and returning an error. - Auth: session (active organization required) - Path parameters: id - Query parameters: loc #### POST /api/websites/{id}/builder/element POST /api/websites/:id/builder/element — change one element. The Edit tab's whole write path. A click in the preview came back with a source location, the panel turned a control into a couple of Tailwind classes, and this puts them in the file. It goes through `writeSiteFile` like everything else, so the same rules apply — inside `src/`, not one of the protected files, and it has to parse afterwards — and it commits, so nudging a font size is a version like any other and Undo takes it back. - Auth: session (active organization required) - Path parameters: id - Body fields: add, attributes, loc, remove, text #### GET /api/websites/{id}/builder/files GET /api/websites/:id/builder/files — the tree, or one file's contents. Both on one route because the code panel does one then the other and they are the same question at different depths: without `?path=` it is "what is this site made of", with it "and what does this part say". The protected files are listed rather than hidden. A gallery poking at their own code should be able to see that `gc.ts` exists and is not theirs to change — a tree that quietly omits it invites the belief that the content arrives by magic. - Auth: public - Path parameters: id #### POST /api/websites/{id}/builder/files POST /api/websites/:id/builder/files — a new file, and any folders it needs. - Auth: public - Path parameters: id - Body fields: contents, path #### PUT /api/websites/{id}/builder/files PUT /api/websites/:id/builder/files — save one file. The write goes through `writeSiteFile`, so a hand edit is held to exactly the rules the chat is: inside `src/`, a file kind a website is made of, and it has to parse. Nothing reaches the disk otherwise, because a file that does not compile shows up as a preview that silently stopped updating. Then it commits, so an edit made here lands in the same history as one made by asking — undo, versions and publish all keep working across both. - Auth: public - Path parameters: id - Body fields: contents, path #### PATCH /api/websites/{id}/builder/files PATCH /api/websites/:id/builder/files — rename, which is also how a file moves. One operation for both because they are the same thing to the person doing it: `about.tsx` becoming `story.tsx` and `about.tsx` becoming `pages/story.tsx` are one sentence apart. - Auth: public - Path parameters: id - Body fields: from, to #### DELETE /api/websites/{id}/builder/files DELETE /api/websites/:id/builder/files?path=… — remove one file. - Auth: public - Path parameters: id - Query parameters: path #### GET /api/websites/{id}/builder/history GET /api/websites/:id/builder/history — every version, and where things stand. The file lists are stripped: the screen shows a timeline, and shipping a content hash per file per version would be most of the payload for something nothing renders. - Auth: session (active organization required) - Path parameters: id #### GET /api/websites/{id}/builder/preview GET /api/websites/:id/builder/preview — where this site's draft is, and whether there is one yet. A draft is a build in the bucket, served by the same edge that serves published sites, so this is mostly a URL. The one piece of work it does is the first one: a gallery that has never been previewed — a new site, or one whose last draft predates the bucket — has nothing at that address, and framing it would show them a 404 where their website should be. So the first visit builds it, which takes about as long as a publish and happens once. - Auth: session (active organization required) - Path parameters: id #### POST /api/websites/{id}/builder/upload POST /api/websites/:id/builder/upload — an image for the conversation. Uploaded before the message is sent rather than with it, so the gallerist sees a thumbnail while they finish typing and so a failed upload is a failed upload rather than a failed turn. The file is public because it has two possible futures and both need it: the model is shown it by URL, and the site may end up serving it. Uploading once and pointing at it twice is the difference between a photograph they dropped in and a copy of it nobody can find later. - Auth: session (active organization required) - Path parameters: id #### POST /api/websites/{id}/builder/version POST /api/websites/:id/builder/version — move through the history. One route for five actions because they are one decision from the gallery's side — "which version of my site am I looking at, and which one can everyone else see" — and splitting them across five endpoints would only mean five copies of the same ownership check. - Auth: session (active organization required) - Path parameters: id - Body fields: action, version #### GET /api/websites/{id}/domain GET /api/websites/:id/domain — where the domain stands. Re-asks Fly at most once a minute. The dashboard polls this while a gallery waits for DNS to propagate, and a settings tab left open overnight should not turn into thousands of calls against someone else's API. - Auth: public - Path parameters: id - Query parameters: force #### PUT /api/websites/{id}/domain PUT /api/websites/:id/domain — attach a gallery's own domain. The certificate is requested immediately, before any DNS exists. That is the right order: Fly starts watching for the records, so the gallery's site comes up on its own as soon as they finish at their registrar, with nothing further to click here. - Auth: public - Path parameters: id - Body fields: domain, domain_check, domain_checked_at, domain_status, domain_verified_at, error, success #### DELETE /api/websites/{id}/domain DELETE /api/websites/:id/domain — go back to the default address. `.sites.gallerycamp.com` never stopped working, so removing a domain is not destructive: the site is still reachable the moment this returns. - Auth: public - Path parameters: id #### POST /api/websites/{id}/upload/{type} POST /api/websites/:id/upload/:type — logo, favicon or hero image. The file arrives under a field named after the kind rather than a generic "file", which is what the settings form sends. - Auth: session (active organization required) - Path parameters: id, type ## Notes for implementers - Query and body fields are derived statically from the handlers. Fields are listed when they could be determined; an empty list means the shape could not be inferred, not that the endpoint takes nothing. - `/api/auth/*` is handled by Better Auth and is not enumerated here; consult the Better Auth documentation for its routes.