# GalleryCamp API > REST API for GalleryCamp, gallery management software covering artists, > artworks, customers, exhibitions, inquiries, orders and invoices. Generated from source on 2026-08-25. Do not edit by hand. ## Conventions - Base URL: http://localhost:3000 - 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 ### artists #### GET /api/artists - Auth: session (active organization required) - Query parameters: 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-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, group, limit, location_id, 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}/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}/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/import - Auth: session (active organization required) - Body fields: artist_external_ref, artwork, 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 — without a trial, since they have already had it. - Auth: public ### blog #### GET /api/blog/categories - Auth: session (active organization required) #### POST /api/blog/categories - Auth: session (active organization required) - Body fields: color, description, name, slug #### 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, 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, 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 #### 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 - Auth: session (active organization required) - Body fields: 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 #### DELETE /api/categories/{id} - Auth: session (active organization required) - Path parameters: id ### 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. - Auth: session (active organization required) - Path parameters: id - Body fields: 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 ### 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) ### events #### GET /api/events - Auth: session (active organization required) - Query parameters: 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/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) - Body fields: address, city, commission_rate, country, description, facebook, gallery_name, instagram, phone, postal_code, state, tax_id, twitter, website #### 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 ### metadata-keys #### GET /api/metadata-keys - Auth: session (active organization required) - Query parameters: 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. - Auth: session (active organization required) - Path parameters: id - Body fields: action, 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, 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. - Auth: session (active organization required) - Path parameters: id ### 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: logo, name, slug #### POST /api/organizations/purge Permanently destroys an organization: its storage objects, then every row that hangs off it, then the organization itself. Ordering matters. Storage goes first 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/galleries/{site} GET /api/public/galleries/:site — 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 #### GET /api/public/galleries/{site}/artists GET /api/public/galleries/:site/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. - Auth: public - Path parameters: site #### GET /api/public/galleries/{site}/artists/{artistId} GET /api/public/galleries/:site/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, artistId #### GET /api/public/galleries/{site}/artwork-status GET /api/public/galleries/:site/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 #### GET /api/public/galleries/{site}/artworks - Auth: public - Path parameters: site #### GET /api/public/galleries/{site}/artworks/{artworkId} GET /api/public/galleries/:site/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, artworkId #### POST /api/public/galleries/{site}/artworks/{artworkId}/inquiries - Auth: public - Path parameters: site, artworkId - Body fields: email, first_name, last_name, message, phone, source #### GET /api/public/galleries/{site}/blog GET /api/public/galleries/:site/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 #### GET /api/public/galleries/{site}/blog/{slug} GET /api/public/galleries/:site/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, slug #### GET /api/public/galleries/{site}/categories GET /api/public/galleries/:site/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 #### GET /api/public/galleries/{site}/events GET /api/public/galleries/:site/events — the public calendar. - Auth: public - Path parameters: site #### POST /api/public/galleries/{site}/events/{eventId}/rsvps POST /api/public/galleries/:site/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, eventId - Body fields: comments, email, name, party_size, source #### GET /api/public/galleries/{site}/exhibitions GET /api/public/galleries/:site/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 #### GET /api/public/galleries/{site}/exhibitions/{exhibitionId} GET /api/public/galleries/:site/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, exhibitionId #### 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/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: 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 ### 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 ### 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 - Auth: session (active organization required) ### 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_primary, is_published, logo, name, primary_color, secondary_color, selection_mode, seo_description, seo_keywords, seo_title, 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 #### 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.