{
  "openapi": "3.1.0",
  "info": {
    "title": "Stixel Maker API",
    "version": "0.1.0",
    "contact": {
      "name": "Backend",
      "url": "https://stixelmaker.com"
    },
    "description": "REST API for the Stixel Maker sticker design app. Backend is a Rust\nRocket service backed by SurrealDB. WebSocket route at `/ws/{project_id}`\ncarries real-time canvas / presence / image-status messages and is\ndescribed separately in the **WebSocket** section at the bottom of this\nspec.\n\n## Authentication\n\nAll authenticated endpoints expect a Stytch session JWT in the\n`Authorization: Bearer <token>` header. Clients obtain the token via\nthe Stytch SDK after a successful sign-in (Magic Link, SMS OTP, or\nOAuth \u2014 currently Google and Facebook).\n\nToken validation on the backend:\n- JWKs are cached from Stytch's discovery endpoint and refreshed on\n  unknown `kid` values.\n- Algorithm: RS256.\n- Required claims: `sub` (Stytch user_id, used as the canonical user\n  id throughout this API), `iss` (`stytch.com/<project_id>`),\n  `aud` (`<project_id>`).\n- 30 s leeway on `exp`.\n\nTwo auth modes are visible on routes:\n- **AuthenticatedUser** \u2014 required. Missing / malformed / expired\n  tokens get HTTP 401 with no body.\n- **OptionalUser** \u2014 best-effort. The route still runs anonymously\n  if no token is present; an invalid token degrades to anonymous\n  rather than 401. Endpoints documented with `security: []` and a\n  note \"OptionalUser\" follow this pattern.\n\nAnonymous browsing creates projects and uploads owned by no one. The\n`POST /me/claim` endpoint transfers those records to the\nauthenticating user during the sign-up flow (see ADR 0003 in the\nrepo).\n\n## Conventions\n\n- All timestamps are ISO 8601 strings (`2026-06-06T12:00:00Z` or\n  RFC 3339 with offset).\n- Record IDs (project, image) are bare string keys, not the\n  `table:key` SurrealDB form. Some endpoints accept either form for\n  backwards compatibility; new clients should use bare keys.\n- Multipart upload routes expect the file in a form field named\n  `file`.\n- Pagination uses opaque cursors (currently ISO timestamps from\n  `created_at` or `last_accessed_at`). Send the last item's\n  timestamp as `cursor` to fetch the next page. Limits clamp to\n  200 server-side.\n"
  },
  "servers": [
    {
      "url": "https://api.stixelmaker.com",
      "description": "Production"
    },
    {
      "url": "http://localhost:8000",
      "description": "Local dev (Rocket default)"
    }
  ],
  "tags": [
    {
      "name": "Health",
      "description": "Liveness / smoke endpoints, no auth required."
    },
    {
      "name": "Catalog",
      "description": "Static product configuration (sizes, presets)."
    },
    {
      "name": "Quote",
      "description": "Sticker-first sizing quotes (docs/sticker-first-reframe.md). The packer\ncomputes how many copies of one sticker fit N-up per Gooten medium and\nwhich (medium, pack) combination best serves the requested quantity.\n"
    },
    {
      "name": "Projects",
      "description": "Project CRUD, cloning, soft-delete, and rename."
    },
    {
      "name": "Project images",
      "description": "Per-project image upload + list + print-asset upload."
    },
    {
      "name": "Me",
      "description": "User-scoped collections \u2014 recents, owned assets, claim."
    },
    {
      "name": "Account",
      "description": "Per-user profile (display name, avatar). Email + OAuth provider live in Stytch and are read client-side via `stytch.user.getSync()`."
    }
  ],
  "security": [],
  "paths": {
    "/": {
      "get": {
        "tags": [
          "Health"
        ],
        "summary": "Service banner",
        "description": "Plain-text marker so smoke checks can identify the service.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                },
                "examples": {
                  "default": {
                    "value": "Stixel Maker Implementation POC Backend"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/health": {
      "get": {
        "tags": [
          "Health"
        ],
        "summary": "Liveness probe",
        "description": "Returns `ok` if the process is up. Does not exercise the DB.",
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string"
                },
                "examples": {
                  "default": {
                    "value": "ok"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/sizes": {
      "get": {
        "tags": [
          "Catalog"
        ],
        "summary": "List available canvas presets",
        "description": "Sticker sheet sizes the editor surfaces in the size picker. Sourced\nfrom `shared/products.json`; never user-mutable.\n",
        "responses": {
          "200": {
            "description": "Array of presets",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CanvasPreset"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/quote": {
      "post": {
        "tags": [
          "Quote"
        ],
        "summary": "Compute a sticker packing quote",
        "description": "Pure compute \u2014 no side effects, no auth. Given a sticker's aspect\nratio, the customer's chosen longest side (inches), and a desired\nminimum quantity, returns the packer's chosen medium, N-up layout,\nand Gooten pack. Pack counts are the printer's constraint\n({1,2,3,4,5,10,25}); when the needed sheet count falls between packs\nthe quote rounds up and `bonus_qty` reports the extra stickers the\ncustomer receives.\n\nThe price is *not* included \u2014 the commerce API's price table, keyed\nby (`medium`, `pack`), is the pricing authority; clients resolve the\nprice via the commerce API's `GET /prices`.\n\nThe aspect ratio is client-supplied here for responsiveness; the\nbackend re-derives it from the stored asset when the draft project\nis generated at cart-add, so a dishonest client cannot skew the\nlayout that is actually printed.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/QuotePayload"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Packing quote",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QuoteResponse"
                }
              }
            }
          },
          "422": {
            "description": "Unpackable input \u2014 body carries a machine-readable reason:\n`size_out_of_bounds`, `does_not_fit`, `quantity_too_large`,\nor `invalid_input`.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QuoteError"
                }
              }
            }
          }
        }
      }
    },
    "/drafts": {
      "post": {
        "tags": [
          "Quote"
        ],
        "summary": "Create a sticker-first draft project",
        "description": "OptionalUser. Commits the customer's sticker lines into a **draft\nproject**, entering the lifecycle at `draft`. Two request shapes are\naccepted, and they behave differently on purpose:\n\n**One line** \u2014 `{asset_id, longest_side_in, qty}`. The web\nstorefront's cart-add. The packer picks the medium and N-up layout and\nthe canvas is seeded with the grid, because the web render depends on\nthe layout existing at order time.\n\n**Many lines** \u2014 `{items: [...]}`. The mobile checkout call\n(`docs/ios-order-data-contract.md`). Creates **one** project carrying\nevery line's intent and a copy of each line's artwork, with **no\nlayout at all**: `canvas.objects` is empty and the sheets are produced\nafter the order by the batch packer, so stickers can be combined\nacross sheets. Every line is validated and priced *before* anything is\nwritten, so a bad line cannot leave a half-built project behind; the\nerror names the offending line via `item_index`.\n\nEach line gets its **own** image copy even when two lines share a\nsource asset \u2014 the same photo can be ordered at two sizes, and each\nline needs a distinct identity so a placed sticker attributes to the\nright one.\n\nThe source asset is a gallery row (strictly owner-matched) or a\nproject-scoped upload (capability model: holding the unguessable\nasset id authorizes the draft, the same trust boundary as a shared\nproject link \u2014 this is how the anonymous sticker workspace and\nphone-QR uploads order without an account).\n\nThe customer never sees this as a \"project\" \u2014 it surfaces as a\nresumable Draft. Each returned quote's (medium, pack) identifies the\nprice-table entry the commerce API charges for; the project id is\nwhat checkout sends to the commerce API.\n\nThe server derives the artwork aspect ratio from the stored processed\nimage; the `aspect` field is a fallback for environments where the\nimage is unreachable (dev without CDN). A dishonest client cannot\nskew the printed layout when the image is readable.\n\nAt most 50 lines per request.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DraftPayload"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Draft created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "project",
                    "quotes"
                  ],
                  "properties": {
                    "project": {
                      "$ref": "#/components/schemas/Project"
                    },
                    "quotes": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PackQuote"
                      },
                      "description": "One quote per requested line, in request order \u2014 the\npricing truth to display before payment\n(`total_stickers`, `bonus_qty`).\n"
                    },
                    "quote": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/PackQuote"
                        },
                        {
                          "type": "null"
                        }
                      ],
                      "description": "**Deprecated \u2014 read `quotes` instead.** Present only on\nsingle-line requests, where it mirrors `quotes[0]`, so\nthe web client keeps working unchanged.\n"
                    },
                    "print_checks": {
                      "type": "array",
                      "items": {
                        "oneOf": [
                          {
                            "$ref": "#/components/schemas/PrintCheck"
                          },
                          {
                            "type": "null"
                          }
                        ],
                        "description": "`null` for a line whose artwork could not be\nmeasured \u2014 not assessed, rather than fine.\n"
                      },
                      "description": "One verdict per requested line, positionally aligned\nwith `quotes`. Positional rather than keyed by asset\nbecause two lines may order the same asset at different\nsizes, which is exactly when the verdicts differ.\n\nServer-derived from the stored image, so unlike the\n`source_px` a client sends to `POST /quote` this cannot\nbe skewed. If the two disagree, believe this one.\n"
                    },
                    "print_check": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/PrintCheck"
                        },
                        {
                          "type": "null"
                        }
                      ],
                      "description": "Present only on single-line requests, mirroring\n`print_checks[0]` \u2014 the same singular/plural pairing as\n`quote`/`quotes`.\n"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Asset missing, deleted, or not a gallery image"
          },
          "403": {
            "description": "Caller does not own the asset"
          },
          "409": {
            "description": "Asset not ready (`asset_not_ready`) or aspect unreadable with no\nusable fallback (`aspect_unavailable`).\n"
          },
          "422": {
            "description": "Packer rejection (same error body as `POST /quote`), an empty\nitem list (`no_items`), more than 50 lines (`too_many_items`),\nor more than 100 stickers requested across all lines\n(`too_many_stickers`). The sticker cap counts what was asked\nfor, not what pack rounding will deliver.\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QuoteError"
                }
              }
            }
          }
        }
      }
    },
    "/projects/{id}/quote": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectId"
        }
      ],
      "get": {
        "tags": [
          "Quote"
        ],
        "summary": "Re-derive a draft's per-line quotes from its stored intents",
        "description": "The `/drafts` response carries quotes only at creation time; checkout\nhappens later, and the checkout service (commerce-api, BRC-64) must\nre-derive the authoritative quote server-side rather than trust a\nclient's copy of it. Clients may also use this to re-price a resumed\ndraft.\n\nPure recomputation from the persisted intents (server-derived aspect\nincluded), so the quotes match what the draft returned. Quotes carry\nthe physical answer \u2014 medium, pack, sticker counts; **prices live in\nthe commerce service**, keyed on `layout.medium_title` + `pack`.\n",
        "operationId": "getProjectQuote",
        "responses": {
          "200": {
            "description": "Per-line quotes, in intent order.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "project_name",
                    "quotes"
                  ],
                  "properties": {
                    "project_name": {
                      "type": "string"
                    },
                    "quotes": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/PackQuote"
                      }
                    },
                    "line_images": {
                      "type": "array",
                      "items": {
                        "type": [
                          "string",
                          "null"
                        ]
                      },
                      "description": "One entry per line, in `quotes` order: the public URL\nof that line's cut-out (the project-scoped image copy's\n`processed_url`), or null if it has none yet. Lets a\ncheckout page show the sticker rather than the\nproject's auto-generated name.\n"
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No such project.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string",
                      "enum": [
                        "project_not_found"
                      ]
                    }
                  }
                }
              }
            }
          },
          "409": {
            "description": "The project has no sticker lines to quote (a legacy sheet\nproject) \u2014 asking is a caller bug, not an empty answer.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error"
                  ],
                  "properties": {
                    "error": {
                      "type": "string",
                      "enum": [
                        "no_quotable_lines"
                      ]
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "A stored line no longer quotes (data drift \u2014 the draft flow\nvalidated these inputs once). `item_index` names the line.\n",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "error",
                    "item_index"
                  ],
                  "properties": {
                    "error": {
                      "type": "string",
                      "enum": [
                        "unquotable_line"
                      ]
                    },
                    "item_index": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/projects/{id}/tally": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectId"
        }
      ],
      "get": {
        "tags": [
          "Quote"
        ],
        "summary": "Ordered-versus-delivered tally (internal, service-to-service)",
        "description": "Internal-only (`X-Internal-Token`). The commerce approve gate calls\nthis at approval time: an order whose current layout would deliver\nfewer stickers than were paid for must not be approvable, from any\ncode path. Same arithmetic the operator proofing panel displays.\n",
        "operationId": "getProjectTally",
        "responses": {
          "200": {
            "description": "Per-line tally plus the aggregate verdict.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "satisfied",
                    "short_lines",
                    "tally"
                  ],
                  "properties": {
                    "satisfied": {
                      "type": "boolean",
                      "description": "Every line delivers at least what was ordered."
                    },
                    "short_lines": {
                      "type": "integer",
                      "description": "Lines currently under-delivering (blocks approval)."
                    },
                    "tally": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/LineTally"
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No such project."
          }
        }
      }
    },
    "/projects/{id}/fulfillment-info": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectId"
        }
      ],
      "get": {
        "tags": [
          "Quote"
        ],
        "summary": "Fulfillment info for the Gooten provider (server-to-server)",
        "description": "Live lookup used by the commerce Gooten fulfillment provider at\nsubmit time. Returns what should actually be printed, plus the\nsticker-first lifecycle state. The provider refuses to submit unless\n`state` is `approved`; legacy projects return `state: null` and keep\nthe pre-reframe manual flow.\n\n**`sheets` is the thing to submit.** An order is packed across sheets\n*after* the customer pays, so the cart's line items describe what was\ncharged, not what gets printed \u2014 submitting from them would send\nGooten sheets that don't exist. Each entry carries its own medium,\npack count and print master, and each becomes one Gooten recipe.\n\nEmpty for legacy and single-canvas projects, which keep the pre-sheet\npath where the customer's own chosen variant is the better authority.\n",
        "responses": {
          "200": {
            "description": "Fulfillment info",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "print_asset_url",
                    "state",
                    "sheets"
                  ],
                  "properties": {
                    "print_asset_url": {
                      "type": [
                        "string",
                        "null"
                      ],
                      "description": "Mirrors the **first** sheet's master. Kept for readers\nthat predate sheets; prefer `sheets`.\n"
                    },
                    "state": {
                      "oneOf": [
                        {
                          "$ref": "#/components/schemas/ProjectState"
                        },
                        {
                          "type": "null"
                        }
                      ]
                    },
                    "sheets": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "required": [
                          "id",
                          "pack"
                        ],
                        "properties": {
                          "id": {
                            "type": "string"
                          },
                          "pack": {
                            "type": "integer",
                            "description": "How many copies of this sheet get printed"
                          },
                          "print_asset_url": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "Null until this sheet has been rendered"
                          },
                          "medium_title": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "description": "Matches `CanvasPreset.title` exactly \u2014 commerce\nkeys its Gooten SKU table on it.\n"
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "Not found"
          }
        }
      }
    },
    "/projects/{id}/ordered": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectId"
        }
      ],
      "post": {
        "tags": [
          "Quote"
        ],
        "summary": "Mark a draft as ordered (service-only)",
        "description": "Advances the lifecycle `draft \u2192 ordered`. Guarded by the\n`X-Internal-Token` header (shared secret, `INTERNAL_RENDER_TOKEN`) \u2014\ncalled by the commerce `order.placed` subscriber before it triggers\nthe print render. Legacy projects (no lifecycle state) return 409 and\nthe caller ignores it.\n",
        "responses": {
          "200": {
            "description": "New state",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "state": {
                      "$ref": "#/components/schemas/ProjectState"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing/invalid internal token"
          },
          "404": {
            "description": "Not found"
          },
          "409": {
            "description": "Illegal transition (legacy project or not in `draft`)"
          }
        }
      }
    },
    "/projects/{id}/approved": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectId"
        }
      ],
      "post": {
        "tags": [
          "Quote"
        ],
        "summary": "Mark a project approved (service-only)",
        "description": "Advances the lifecycle `rendered \u2192 approved`. Guarded by the\n`X-Internal-Token` header (shared secret, `INTERNAL_RENDER_TOKEN`) \u2014\ncalled by commerce-api when an operator approves the ORDER, which is\nthe business-state authority (BRC-63/BRC-72). Keeps design-side\nsurfaces truthful and makes the later `submitted` flip legal. Never\ncalled by clients.\n",
        "operationId": "markProjectApproved",
        "responses": {
          "200": {
            "description": "New state",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "state": {
                      "$ref": "#/components/schemas/ProjectState"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing/invalid internal token"
          },
          "404": {
            "description": "Not found"
          },
          "409": {
            "description": "Illegal transition (project not in `rendered`)"
          }
        }
      }
    },
    "/projects/{id}/submitted": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectId"
        }
      ],
      "post": {
        "tags": [
          "Quote"
        ],
        "summary": "Mark a sheet as submitted to Gooten (service-only)",
        "description": "Advances the lifecycle `approved \u2192 submitted`. Guarded by the\n`X-Internal-Token` header (shared secret, `INTERNAL_RENDER_TOKEN`) \u2014\ncalled by the commerce Gooten provider after a successful live\nsubmission, never by clients.\n",
        "responses": {
          "200": {
            "description": "New state",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "state": {
                      "$ref": "#/components/schemas/ProjectState"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing/invalid internal token"
          },
          "404": {
            "description": "Not found"
          },
          "409": {
            "description": "Illegal transition (project not in `approved`)"
          }
        }
      }
    },
    "/projects": {
      "get": {
        "tags": [
          "Projects"
        ],
        "summary": "List the authenticated user's owned projects",
        "description": "Returns projects where `owner_id` matches the JWT `sub`. Does\n**not** include shared / collaborated / viewed projects \u2014 see\n`GET /me/projects` for the full Recents-joined view.\n",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Array of owned projects",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Project"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "tags": [
          "Projects"
        ],
        "summary": "Create a new project from a preset",
        "description": "OptionalUser. Logged-in callers become the owner and get a Recents\nrow; anonymous callers create an unowned project that may be\nclaimed later via `POST /me/claim`.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateProjectPayload"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "New project",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/projects/{id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectId"
        }
      ],
      "get": {
        "tags": [
          "Projects"
        ],
        "summary": "Get a project by id",
        "description": "OptionalUser. Accepts either bare key (`abc123`) or the legacy\n`projects:abc123` form. Authenticated callers also get a\nRecents-row touch as a side effect (`last_accessed_at = now`).\n",
        "responses": {
          "200": {
            "description": "Project",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "404": {
            "description": "Not found"
          }
        }
      },
      "put": {
        "tags": [
          "Projects"
        ],
        "summary": "Update a project's canvas + name",
        "description": "OptionalUser. Replaces `canvas` and `nm` on the existing record.\nOther fields (collaborators, owner, etc.) are not editable through\nthis endpoint \u2014 use the dedicated endpoints (`PATCH .../name` for\nrename; `POST .../delete` for soft-delete).\n\n\u26a0 This route does not currently enforce ownership \u2014 any client\nwith a valid project id can submit a canvas update. Treat with\ncare from a mobile client and gate the UI accordingly.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/Project"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated project",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "404": {
            "description": "Project not found"
          }
        }
      }
    },
    "/projects/{id}/delete": {
      "post": {
        "tags": [
          "Projects"
        ],
        "summary": "Soft-delete a project (owner only)",
        "description": "Sets `deleted_at`. Project records are never hard-deleted (ADR\n0005). Other users' Recents rows pointing at the project become\ntombstones the frontend renders with a deleted treatment.\n",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/ProjectId"
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Caller is not the owner"
          },
          "404": {
            "description": "Project not found"
          }
        }
      }
    },
    "/projects/{id}/undelete": {
      "post": {
        "tags": [
          "Projects"
        ],
        "summary": "Restore a soft-deleted project (owner only)",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/ProjectId"
          }
        ],
        "responses": {
          "200": {
            "description": "Restored"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Caller is not the owner"
          },
          "404": {
            "description": "Project not found"
          }
        }
      }
    },
    "/projects/{id}/name": {
      "patch": {
        "tags": [
          "Projects"
        ],
        "summary": "Rename a project (owner only)",
        "description": "Trims whitespace server-side and rejects empty / whitespace-only\nnames with 400.\n",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/ProjectId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RenamePayload"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated project",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "400": {
            "description": "Empty or whitespace-only name"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Caller is not the owner"
          },
          "404": {
            "description": "Project not found"
          }
        }
      }
    },
    "/projects/{id}/clone": {
      "post": {
        "tags": [
          "Projects"
        ],
        "summary": "Duplicate a project",
        "description": "OptionalUser. Creates a new project named \"Copy of <original>\"\nwith the same canvas + image references. Authenticated callers\nbecome the owner of the clone; anonymous callers get an unowned\ncopy.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/ProjectId"
          }
        ],
        "responses": {
          "200": {
            "description": "New cloned project",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "404": {
            "description": "Source project not found"
          }
        }
      }
    },
    "/projects/{id}/associate": {
      "post": {
        "tags": [
          "Projects"
        ],
        "summary": "Take ownership of an unowned project (auth required)",
        "description": "If the project currently has no owner, claims it for the caller.\nUsed by the desktop \"Save My Work\" anonymous-to-authenticated flow\nwhen the user signs in mid-edit.\n",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/ProjectId"
          }
        ],
        "responses": {
          "200": {
            "description": "Updated project",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "description": "Project not found"
          }
        }
      }
    },
    "/projects/{id}/images": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectId"
        }
      ],
      "get": {
        "tags": [
          "Project images"
        ],
        "summary": "List images attached to a project",
        "description": "OptionalUser. Returns the project's images in upload order.",
        "responses": {
          "200": {
            "description": "Array of image assets",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ImageAsset"
                  }
                }
              }
            }
          },
          "404": {
            "description": "Project not found"
          }
        }
      },
      "post": {
        "tags": [
          "Project images"
        ],
        "summary": "Upload an image to a project",
        "description": "OptionalUser. Multipart form with field name `file`. The route\nsniffs the first 32 bytes for a supported format signature\n(JPEG / PNG / WebP / HEIC) and rejects unknown formats with 415.\n\nHEIC handling: only accepted when the backend was built with the\n`heic` cargo feature (libheif present). Without it HEIC also\nreturns 415 \u2014 clients should pre-convert (iOS Safari does this\nautomatically when `accept` is JPEG-only).\n\nThe response is the **freshly-created** ImageAsset with\n`status: Uploading`. Background processing (remove.bg \u2192\nCloudflare Images) runs asynchronously; subscribe to the project\nWebSocket for `Processing \u2192 Ready` status updates and the final\n`processed_url`.\n\nBackground removal is skipped when the upload is already a cutout \u2014\neither because the client set `preprocessed`, or because the image\nvisibly uses transparency. The uploaded bytes then become the\nprocessed asset directly (canonicalized to PNG).\n\nMaximum upload size is 20 MB (driven by `config.json:\nmaxImageUploadMb`).\n",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  },
                  "preprocessed": {
                    "type": "boolean",
                    "default": false,
                    "description": "Assert that the file is already background-removed, so\nremove.bg is skipped and the upload is stored as the\nprocessed asset as-is. Optional; omitting it is\nequivalent to `false`.\n\nServer-side alpha detection applies either way, so a\ngenuine cutout skips removal even without this flag. Set\nit when the client already knows (e.g. re-uploading a\nsticker this service produced), or to force the skip for\nan image whose transparency is too sparse to detect.\n"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "404": {
            "description": "Project not found \u2014 uploads into a project that no longer exists are refused",
            "not orphaned": null
          },
          "200": {
            "description": "Created asset (still Uploading)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ImageAsset"
                }
              }
            }
          },
          "415": {
            "description": "Unsupported image format"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      }
    },
    "/projects/{id}/print_asset": {
      "post": {
        "tags": [
          "Project images"
        ],
        "summary": "Upload a print-ready PNG for a project (legacy)",
        "description": "Multipart form with field name `file`; stored to R2 as the project's\n`print_asset_url`.\n\n**Legacy / no current caller.** The print master is now rendered\n**server-side** on demand (`POST /projects/{id}/render`) rather than\nuploaded by the client \u2014 the web editor no longer calls this. Kept as a\nvalid endpoint; see ADR 0013.\n\n\u26a0 Currently no auth guard \u2014 callers can attach a print asset to\nany project id they know. Documented as-is.\n",
        "parameters": [
          {
            "$ref": "#/components/parameters/ProjectId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated project with `print_asset_url`",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Project"
                }
              }
            }
          },
          "404": {
            "description": "Project not found"
          }
        }
      }
    },
    "/projects/{id}/render": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectId"
        }
      ],
      "post": {
        "tags": [
          "Project images"
        ],
        "summary": "Trigger (or join) an async server-side print render",
        "description": "Renders the project's print masters server-side (Cloudflare Browser\nRendering \u2192 R2) on a background task and returns immediately with the\ncurrent status. A multi-sheet project renders **one master per\nsheet**, sequentially. **Single-flight per project**:\nif a render is already in flight, this coalesces onto it rather than\nstarting a second. Poll `GET` for completion. Open (project-scoped),\nlike the other `/projects/{id}` routes. Drivers: the editor's\nDownload-PNG button and the commerce `order.placed` subscriber.\n",
        "responses": {
          "200": {
            "description": "Current render status (render started or already running)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RenderStatus"
                }
              }
            }
          }
        }
      },
      "get": {
        "tags": [
          "Project images"
        ],
        "summary": "Poll a project's print-render status",
        "description": "Returns the current render status. Falls back to the project's\npersisted `print_asset_url` when this process has no in-memory record\n(e.g. after a restart, or a render completed in a prior run), so a\ncompleted asset always reports `ready`.\n\nA project can be several sheets, each with its own print master, so\nthe response also lists them. `status: ready` requires **every** sheet\nto have a master \u2014 an order with one still missing reports\n`rendering`, because approving it would submit sheets that don't exist\nyet. A single-canvas project reports one sheet and behaves exactly as\nbefore.\n",
        "responses": {
          "200": {
            "description": "Render status",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RenderStatusDetail"
                }
              }
            }
          }
        }
      }
    },
    "/projects/{id}/thumbnail": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ProjectId"
        }
      ],
      "post": {
        "tags": [
          "Project images"
        ],
        "summary": "Ensure the project's canvas thumbnail is fresh",
        "description": "Lazily (re)renders the project's canvas thumbnail (ADR 0006) if stale\nand returns its URL. Called at checkout so the cart shows a current\ndesign preview without depending on the heavier, async print render.\n",
        "responses": {
          "200": {
            "description": "The current thumbnail URL (null if it couldn't be produced)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "thumbnail_url"
                  ],
                  "properties": {
                    "thumbnail_url": {
                      "type": [
                        "string",
                        "null"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/me/projects": {
      "get": {
        "tags": [
          "Me"
        ],
        "summary": "List the caller's projects via Recents (owned + collab + viewed)",
        "description": "Returns the Recents-joined view that backs the home screen\nProjects tab. Each row carries the derived role (`Owner` |\n`Collab` | `Viewed`) so the UI can render different treatments\nfor shared vs owned projects.\n\nSorted by `last_accessed_at` DESC. Pagination via `cursor`\n(timestamp of the last item).\n",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "Array of recent-projects rows",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/RecentProject"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/me/assets": {
      "get": {
        "tags": [
          "Me"
        ],
        "summary": "List the caller's gallery image assets",
        "description": "Returns the caller's *gallery* images \u2014 reusable, top-level copies\nowned by the user (`owner_id` matches the JWT `sub` and\n`project_id` is null). Project-owned image copies are excluded;\nthey live and die with their project. Sorted by `created_at` DESC.\nCursor is an ISO timestamp.\n",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Limit"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          }
        ],
        "responses": {
          "200": {
            "description": "Array of owned assets",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ImageAsset"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "post": {
        "tags": [
          "Me"
        ],
        "summary": "Upload a photo straight into the caller's gallery",
        "description": "Sticker-first flow (Phase 4b): creates a *gallery* image\n(`project_id = null`) directly \u2014 no project involved. Runs the same\npipeline as project uploads (format sniffing, background removal).\nThe returned asset starts as `Uploading`; there is **no WebSocket\nroom** for gallery uploads \u2014 poll `GET /me/assets` until the asset\nis `Ready` (or `Error`). Multipart form, file field named `file`.\n\nAs with project uploads, background removal is skipped when the\nimage is already a cutout (client-asserted via `preprocessed`, or\ndetected from its alpha channel).\n",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  },
                  "preprocessed": {
                    "type": "boolean",
                    "default": false,
                    "description": "Assert that the file is already background-removed, so\nremove.bg is skipped and the upload is stored as the\nprocessed asset as-is. Optional; server-side alpha\ndetection applies either way.\n"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The new gallery asset (status `Uploading`)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ImageAsset"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "415": {
            "description": "Unsupported image format"
          }
        }
      }
    },
    "/me/assets/{asset_id}": {
      "patch": {
        "tags": [
          "Me"
        ],
        "summary": "Toggle the hidden flag on an asset (owner only)",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "asset_id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HiddenPatch"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Caller is not the owner of the asset"
          },
          "404": {
            "description": "Asset not found"
          }
        }
      },
      "delete": {
        "tags": [
          "Me"
        ],
        "summary": "Soft-delete a gallery image (owner only)",
        "description": "Sets `deleted_at` on the gallery image \u2014 the user-facing tombstone.\nThe image disappears from the gallery immediately; an operator later\nhard-deletes it (Cloudflare object + row) from the admin cleanup\nscreen. Distinct from the reversible `hidden` toggle (PATCH).\n",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "asset_id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Soft-deleted"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "description": "Caller is not the owner of the asset"
          },
          "404": {
            "description": "Asset not found"
          }
        }
      }
    },
    "/me/recents/{project_id}": {
      "parameters": [
        {
          "in": "path",
          "name": "project_id",
          "required": true,
          "schema": {
            "type": "string"
          }
        }
      ],
      "patch": {
        "tags": [
          "Me"
        ],
        "summary": "Toggle the per-user hidden flag on a Recents row",
        "description": "Hides a project from the caller's Recents grid without affecting\nownership or membership. Other users still see the project\nnormally.\n",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HiddenPatch"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      },
      "delete": {
        "tags": [
          "Me"
        ],
        "summary": "Remove a Recents row for the caller",
        "description": "Hard-deletes the row from the caller's Recents \u2014 re-engaging\n(view or edit) re-creates it.\n",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Removed"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/me/claim": {
      "post": {
        "tags": [
          "Me"
        ],
        "summary": "Apply an anonymous-claim payload (sign-up flow)",
        "description": "Called by the frontend after sign-up to attribute anonymous\nprojects and uploaded assets to the freshly-authenticated user.\nThe payload is built from the browser's localStorage record of\nwhat the anonymous session touched.\n\nPer-item failures are tolerated silently (the payload may be\nstale \u2014 projects deleted, assets gone), so this endpoint always\nreturns 200 if the JWT is valid.\n\nSee ADR 0003 in the repo for the design rationale.\n",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ClaimPayload"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Claim applied (best-effort)"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          }
        }
      }
    },
    "/me/account": {
      "get": {
        "tags": [
          "Account"
        ],
        "summary": "Get the caller's account profile",
        "description": "Returns the persistent account row (display_name + avatar_url).\nLazy-creates an empty row on first call so signup flows don't\nneed a separate hook.\n\nEmail and OAuth provider are **not** stored backend-side. Read\nthem from Stytch on the client via `stytch.user.getSync()`.\n",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Account",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Account"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "patch": {
        "tags": [
          "Account"
        ],
        "summary": "Update the caller's profile",
        "description": "Every field is optional and absence means \"leave it alone\", so the\nprofile page can save one section without resending the others.\n\n* `display_name` \u2014 blank input clears it (server trims). No length\n  or content validation beyond that.\n* `shipping_address` \u2014 an object replaces the stored address;\n  explicit `null`, or an object whose every line is blank, clears\n  it. Absent leaves it untouched.\n* `notifications` \u2014 replaces the preferences wholesale.\n",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AccountPatch"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated account",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Account"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        }
      },
      "delete": {
        "tags": [
          "Account"
        ],
        "summary": "Delete the caller's account",
        "description": "Ends the account, for real \u2014 App Store Review Guideline 5.1.1(v),\nwhich requires an app offering account creation to offer account\ndeletion inside the app.\n\nThree things happen, in this order:\n\n1. **The Stytch user is deleted.** This goes first because it is the\n   step most likely to fail, and a failure here means nothing else\n   has been touched: the caller keeps their session and their data,\n   and the response says the account was not deleted. While the\n   Stytch user exists a magic link to that address still signs in,\n   so this \u2014 not the rows below \u2014 is what makes a deletion real.\n2. **Orders are unlinked** on the commerce API: the Stytch id, the\n   email and the shipping address come off the order rows, while\n   the money, the line items and the lifecycle stay. Order records\n   outlive the customer for accounting; the person behind them does\n   not.\n3. **Design data is deleted** \u2014 for real, storage included. Projects\n   and gallery images are purged along with their Cloudflare Images\n   objects and R2 print masters, and the account row and recents go\n   with them. This is not the tombstone the customer's own Remove\n   button leaves: that one waits for an operator to clear it, and a\n   closed account should not wait on anybody.\n\nSteps 2 and 3 run after the caller can no longer authenticate, so a\nfailure in either is logged rather than returned: reporting failure\nfor an account that is already gone would be worse than useless to\nthe person who asked.\n\nAnonymous work is untouched \u2014 a project with no owner was never\nattached to this person.\n",
        "operationId": "deleteMeAccount",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "responses": {
          "204": {
            "description": "Account deleted. Nothing is returned."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "502": {
            "description": "The identity provider refused the deletion. Nothing was\nchanged \u2014 the account, its data and the caller's session are\nall intact.\n"
          },
          "503": {
            "description": "Account deletion is not configured on this deployment\n(no Stytch project secret). Nothing was changed.\n"
          }
        }
      }
    },
    "/me/avatar": {
      "post": {
        "tags": [
          "Account"
        ],
        "summary": "Upload an avatar image",
        "description": "Multipart form with field name `file`. Bytes are pushed to\nCloudflare Images and the resulting CDN URL is stored as\n`avatar_url` on the Account record. Returns the updated Account.\n\nRequires Cloudflare Images credentials in env\n(`CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_API_TOKEN`). Returns 502 if\nCloudflare rejects the upload.\n",
        "security": [
          {
            "BearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": [
                  "file"
                ],
                "properties": {
                  "file": {
                    "type": "string",
                    "format": "binary"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated account with avatar_url",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Account"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          },
          "502": {
            "description": "Cloudflare Images upload failed"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "Stytch session JWT. See top-of-doc `Authentication` section for\nhow to obtain a token and what claims must be present.\n"
      }
    },
    "parameters": {
      "ProjectId": {
        "in": "path",
        "name": "id",
        "required": true,
        "description": "Project record id. Bare key (e.g. `wxgfzld9u9zu6w5omwlc`) is the\ncanonical form. Legacy `projects:wxgfzld9u9zu6w5omwlc` is also\naccepted on read paths.\n",
        "schema": {
          "type": "string"
        }
      },
      "Limit": {
        "in": "query",
        "name": "limit",
        "required": false,
        "schema": {
          "type": "integer",
          "default": 50,
          "maximum": 200,
          "minimum": 1
        }
      },
      "Cursor": {
        "in": "query",
        "name": "cursor",
        "required": false,
        "description": "Opaque pagination cursor. For Recents (`/me/projects`) this is\nthe `last_accessed_at` of the last item; for owned assets\n(`/me/assets`) it's the `created_at`. Both are ISO 8601\ntimestamps.\n",
        "schema": {
          "type": "string"
        }
      }
    },
    "responses": {
      "Unauthorized": {
        "description": "Missing, malformed, or expired JWT"
      },
      "InternalError": {
        "description": "Unexpected server error"
      }
    },
    "schemas": {
      "CanvasPreset": {
        "type": "object",
        "required": [
          "id",
          "w",
          "h",
          "title",
          "price_cents",
          "default",
          "print_at_home",
          "disabled"
        ],
        "properties": {
          "id": {
            "type": "integer"
          },
          "w": {
            "type": "number",
            "format": "float",
            "description": "Width in inches"
          },
          "h": {
            "type": "number",
            "format": "float",
            "description": "Height in inches"
          },
          "title": {
            "type": "string"
          },
          "price_cents": {
            "type": "integer"
          },
          "default": {
            "type": "boolean"
          },
          "print_at_home": {
            "type": "boolean"
          },
          "print_stock_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "disabled": {
            "type": "boolean"
          }
        }
      },
      "CanvasObject": {
        "type": "object",
        "required": [
          "id",
          "image_id",
          "x",
          "y",
          "rotation",
          "width",
          "height"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "image_id": {
            "type": "string"
          },
          "x": {
            "type": "number",
            "format": "float"
          },
          "y": {
            "type": "number",
            "format": "float"
          },
          "rotation": {
            "type": "number",
            "format": "float"
          },
          "width": {
            "type": "number",
            "format": "float"
          },
          "height": {
            "type": "number",
            "format": "float"
          },
          "is_placeholder": {
            "type": [
              "boolean",
              "null"
            ]
          }
        }
      },
      "Canvas": {
        "type": "object",
        "required": [
          "width",
          "height",
          "dpi",
          "objects"
        ],
        "properties": {
          "width": {
            "type": "number",
            "format": "float",
            "description": "Inches"
          },
          "height": {
            "type": "number",
            "format": "float",
            "description": "Inches"
          },
          "dpi": {
            "type": "integer"
          },
          "objects": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CanvasObject"
            }
          }
        }
      },
      "CollaboratorRole": {
        "type": "string",
        "enum": [
          "Owner",
          "Collab"
        ]
      },
      "Collaborator": {
        "type": "object",
        "required": [
          "user_id",
          "role"
        ],
        "properties": {
          "user_id": {
            "type": "string"
          },
          "role": {
            "$ref": "#/components/schemas/CollaboratorRole"
          }
        }
      },
      "Project": {
        "type": "object",
        "required": [
          "nm",
          "created",
          "images",
          "canvas",
          "owner_id",
          "auto_arrange",
          "collaborators"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Bare key. Absent when serialized in a context where the\nrecord hasn't been persisted yet.\n"
          },
          "nm": {
            "type": "string",
            "description": "Project name. (Field is named `nm` historically.)"
          },
          "created": {
            "type": "string",
            "description": "ISO 8601 creation timestamp"
          },
          "images": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Image ids attached to the canvas"
          },
          "canvas": {
            "$ref": "#/components/schemas/Canvas"
          },
          "owner_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "print_asset_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "auto_arrange": {
            "type": "boolean"
          },
          "collaborators": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Collaborator"
            }
          },
          "deleted_at": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO timestamp of soft-delete, or null"
          },
          "canvas_updated_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "thumbnail_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "CDN URL of the auto-generated project thumbnail"
          },
          "thumbnail_generated_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "intent": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/StickerIntent"
              },
              {
                "type": "null"
              }
            ],
            "description": "**Deprecated \u2014 read `intents` instead.** Mirrors the first entry\nof `intents` so clients written before a project could hold a\nwhole order keep working. Absent on legacy sheet projects.\n"
          },
          "intents": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/StickerIntent"
            },
            "description": "Every sticker line the customer ordered: one entry per\n`{asset, size, quantity}`. A project now represents an entire\norder rather than a single sheet, so an order with four different\nstickers is one project with four intents.\n\nAbsent or empty on legacy sheet projects and on projects written\nbefore this field existed \u2014 in that case fall back to `intent`.\n"
          },
          "sheets": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Sheet"
            },
            "description": "The printable sheets this project resolves to. An order's stickers\nare packed across sheets, and stickers from different lines can\nshare one.\n\nAbsent or empty in two different situations, which\n`intents` tells apart:\n\n- **Legacy / single-sheet project** (no `intents`) \u2014 the layout is\n  in `canvas`; treat the project as one sheet.\n- **Awaiting layout** (has `intents`) \u2014 the customer's lines are\n  recorded but nothing has been packed yet. There is genuinely no\n  layout, and `canvas.objects` is empty.\n\nWhen sheets *are* present, `canvas` mirrors `sheets[0].canvas` so\nreaders written before sheets keep working. Prefer `sheets`:\n`canvas` shows only the first of them.\n"
          },
          "state": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ProjectState"
              },
              {
                "type": "null"
              }
            ],
            "description": "Sticker-first lifecycle state. Absent on legacy projects, which\nkeep the pre-reframe manual fulfillment flow.\n"
          }
        }
      },
      "Sheet": {
        "type": "object",
        "required": [
          "id",
          "preset_id",
          "pack",
          "canvas"
        ],
        "description": "One printable sheet: a medium, how many copies of it to print, and the\nartwork laid out on it.\n",
        "properties": {
          "id": {
            "type": "string",
            "description": "Stable identifier, so moving a sticker between sheets refers to a\nsheet that survives reordering.\n"
          },
          "preset_id": {
            "type": "integer",
            "description": "Which Gooten medium \u2014 matches `CanvasPreset.id` from `/sizes`"
          },
          "pack": {
            "type": "integer",
            "description": "How many identical copies of this sheet to print (Gooten's pack\nconstraint: 1, 2, 3, 4, 5, 10 or 25).\n\nThis is why moving a sticker between sheets changes delivered\nquantities: a slot on a `pack: 10` sheet yields ten stickers, the\nsame slot on a `pack: 2` sheet yields two.\n"
          },
          "canvas": {
            "$ref": "#/components/schemas/Canvas"
          },
          "print_asset_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "This sheet's print master in R2, once rendered"
          },
          "state": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ProjectState"
              },
              {
                "type": "null"
              }
            ],
            "description": "Per-sheet lifecycle. The project's own `state` is the minimum\nacross its sheets, so an order is only `approved` when every sheet\nis.\n"
          },
          "thumbnail_url": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "StickerIntent": {
        "type": "object",
        "required": [
          "asset_id",
          "longest_side_in",
          "desired_min_qty"
        ],
        "description": "What the customer asked for on the sizing page \u2014 preserved verbatim,\ndistinct from what the packer produced (medium choice, bonus\nstickers).\n",
        "properties": {
          "asset_id": {
            "type": "string",
            "description": "Gallery asset the sticker was made from"
          },
          "longest_side_in": {
            "type": "number",
            "description": "Customer's slider value \u2014 the sticker's longest side, inches"
          },
          "desired_min_qty": {
            "type": "integer",
            "description": "Desired minimum quantity. Pack rounding may deliver more (bonus\nstickers); it never delivers fewer.\n"
          },
          "aspect": {
            "type": [
              "number",
              "null"
            ],
            "description": "Width / height of the **visible subject** \u2014 the cut-out artwork's\nown bounding box, not the padded image's \u2014 resolved when the line\nwas created.\n\nStored rather than re-derived because layout runs after the order\nis placed, and re-reading every line's artwork then would mean an\nimage fetch and decode per line on the order path. Null on lines\ncreated before this field existed.\n"
          },
          "image_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The project-scoped image copy representing this line.\n\nEach line gets its own copy even when two lines share a source\nasset, because the same photo can be ordered at two different\nsizes. That makes this the unambiguous key for attributing a\nplaced sticker back to the line it satisfies. Null on projects\ncreated before this field existed.\n"
          }
        }
      },
      "ProjectState": {
        "type": "string",
        "enum": [
          "draft",
          "ordered",
          "rendered",
          "approved",
          "submitted"
        ],
        "description": "Sticker-first project lifecycle, forward-only:\ndraft \u2192 ordered \u2192 rendered \u2192 approved \u2192 submitted. A proofing\nadjustment that regenerates the print master moves approved back to\nrendered (re-approval required). Gooten submission is refused unless\nthe state is `approved`.\n"
      },
      "QuotePayload": {
        "type": "object",
        "required": [
          "aspect",
          "longest_side_in",
          "qty"
        ],
        "properties": {
          "aspect": {
            "type": "number",
            "description": "Sticker artwork width/height ratio (> 0)"
          },
          "longest_side_in": {
            "type": "number",
            "description": "Chosen longest side, inches (slider value)"
          },
          "qty": {
            "type": "integer",
            "description": "Desired minimum quantity (>= 1)"
          },
          "source_px": {
            "type": "integer",
            "description": "Longest side of the artwork's **visible subject**, in pixels, for\nthe print-quality check. Optional and client-measured: this\nendpoint does no asset lookup, and fetching the image on every\ndrag of the size slider would be absurd. A wrong value yields a\nwrong *warning* and nothing else \u2014 `POST /drafts` computes the\nauthoritative verdict from the stored image.\n\nMeasure the opaque bounding box, not the image: a cut-out sits in\na transparent margin that carries no detail, and counting it\noverstates resolution.\n\nOmit (or send 0) for no verdict.\n"
          }
        }
      },
      "LineTally": {
        "type": "object",
        "required": [
          "asset_id",
          "longest_side_in",
          "required",
          "delivered",
          "short_by",
          "bonus"
        ],
        "description": "One order line's ordered-versus-delivered arithmetic. A sheet is\nprinted `pack` times, so one slot on a 10-pack sheet delivers ten\nstickers \u2014 this is the arithmetic that catches a layout silently\nshorting a line.\n",
        "properties": {
          "asset_id": {
            "type": "string"
          },
          "image_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "longest_side_in": {
            "type": "number"
          },
          "required": {
            "type": "integer",
            "description": "What the customer ordered."
          },
          "delivered": {
            "type": "integer",
            "description": "What the current layout would print."
          },
          "short_by": {
            "type": "integer",
            "description": "How many short; any non-zero value blocks approval."
          },
          "bonus": {
            "type": "integer",
            "description": "Surplus over the order \u2014 harmless pack rounding."
          }
        }
      },
      "SheetLayout": {
        "type": "object",
        "required": [
          "preset_id",
          "medium_title",
          "medium_w_in",
          "medium_h_in",
          "sticker_w_in",
          "sticker_h_in",
          "rotated",
          "cols",
          "rows",
          "n_up"
        ],
        "properties": {
          "preset_id": {
            "type": "integer",
            "description": "Matches `CanvasPreset.id` from `/sizes`"
          },
          "medium_title": {
            "type": "string"
          },
          "medium_w_in": {
            "type": "number"
          },
          "medium_h_in": {
            "type": "number"
          },
          "sticker_w_in": {
            "type": "number",
            "description": "Sticker width after aspect resolution (pre-rotation)"
          },
          "sticker_h_in": {
            "type": "number"
          },
          "rotated": {
            "type": "boolean",
            "description": "Whether the sticker is rotated 90\u00b0 on the sheet"
          },
          "cols": {
            "type": "integer"
          },
          "rows": {
            "type": "integer"
          },
          "n_up": {
            "type": "integer",
            "description": "Copies per sheet (cols \u00d7 rows)"
          }
        }
      },
      "PrintCheck": {
        "type": "object",
        "required": [
          "effective_dpi",
          "band"
        ],
        "description": "Whether the artwork has the pixels for the requested print size.\n\nPrint masters render at 300 dpi and nothing in the pipeline invents\ndetail, so a cut-out from a small or heavily-cropped photo stretched\nup the size slider prints soft. This says so.\n\n**Advisory only.** A `poor` band never blocks a quote, a draft, or an\norder. Clients should show a warning and let the customer decide.\n\nAbsent from a response means *not assessed* \u2014 never \"fine\".\n",
        "properties": {
          "effective_dpi": {
            "type": "number",
            "description": "Pixels per printed inch at the requested size:\n`subject_longest_side_px / longest_side_in`.\n"
          },
          "band": {
            "type": "string",
            "enum": [
              "good",
              "fair",
              "poor"
            ],
            "description": "`good` \u2014 at or above 300 dpi; show nothing.\n`fair` \u2014 150\u2013299 dpi; fine at arm's length, soft up close.\n`poor` \u2014 below 150 dpi; visibly soft or pixelated at this size.\n"
          },
          "sharp_up_to_in": {
            "type": "number",
            "description": "Largest longest-side, in inches, that would still reach 300 dpi.\nPresent only when `band` is not `good` \u2014 the useful thing to tell\nsomeone standing at a size slider is which size works, not that\nthis one doesn't.\n"
          }
        }
      },
      "QuoteResponse": {
        "allOf": [
          {
            "$ref": "#/components/schemas/PackQuote"
          },
          {
            "type": "object",
            "properties": {
              "print_check": {
                "$ref": "#/components/schemas/PrintCheck"
              }
            }
          }
        ],
        "description": "A `PackQuote` with one additional key. Every packing field sits at the\ntop level exactly as it always has; `print_check` is additive and\npresent only when the request carried a usable `source_px`.\n"
      },
      "PackQuote": {
        "type": "object",
        "required": [
          "layout",
          "sheets_needed",
          "pack",
          "total_stickers",
          "bonus_qty",
          "pack_sheets"
        ],
        "properties": {
          "layout": {
            "$ref": "#/components/schemas/SheetLayout"
          },
          "sheets_needed": {
            "type": "integer",
            "description": "Sheets required to satisfy the requested quantity"
          },
          "pack": {
            "type": "integer",
            "description": "The Gooten pack rounded up to \u2014 the number of identical sheets\nprinted. Identifies the price-table entry together with the medium.\n"
          },
          "total_stickers": {
            "type": "integer",
            "description": "pack \u00d7 n_up \u2014 what the customer actually receives"
          },
          "bonus_qty": {
            "type": "integer",
            "description": "total_stickers \u2212 requested qty"
          },
          "pack_sheets": {
            "type": "integer",
            "description": "Alias of `pack` (sheet count of the pack)"
          }
        }
      },
      "QuoteError": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "enum": [
              "size_out_of_bounds",
              "does_not_fit",
              "quantity_too_large",
              "invalid_input",
              "no_items",
              "too_many_items",
              "too_many_stickers"
            ]
          },
          "item_index": {
            "type": "integer",
            "description": "Which line failed, on `POST /drafts`. Zero-based index into the\nrequest's `items`; always 0 for a single-line request. Absent on\n`POST /quote`, which has no lines.\n"
          }
        }
      },
      "DraftPayload": {
        "description": "Either a whole order (`{items: [...]}`) or a single line posted at the\ntop level. The single-line form is what the web storefront sends and\nstays valid indefinitely.\n",
        "oneOf": [
          {
            "$ref": "#/components/schemas/DraftItemsPayload"
          },
          {
            "$ref": "#/components/schemas/DraftItem"
          }
        ]
      },
      "DraftItemsPayload": {
        "type": "object",
        "required": [
          "items"
        ],
        "properties": {
          "items": {
            "type": "array",
            "minItems": 1,
            "maxItems": 50,
            "items": {
              "$ref": "#/components/schemas/DraftItem"
            },
            "description": "Every sticker line in the order. Produces **one** project with no\nlayout \u2014 sheets are packed after the order is placed.\n"
          }
        }
      },
      "DraftItem": {
        "type": "object",
        "required": [
          "asset_id",
          "longest_side_in"
        ],
        "description": "One sticker line. Exactly one of `qty` or `desired_min_qty` must be\npresent \u2014 they are the same field, with the second accepted so the\nnaming in `docs/ios-order-data-contract.md` works as written.\n",
        "properties": {
          "asset_id": {
            "type": "string",
            "description": "Gallery asset owned by the caller, or a project-scoped upload\nwhose id the caller holds.\n"
          },
          "longest_side_in": {
            "type": "number",
            "description": "Chosen longest side, inches (slider value)"
          },
          "qty": {
            "type": "integer",
            "description": "Desired minimum quantity (>= 1)"
          },
          "desired_min_qty": {
            "type": "integer",
            "description": "Alias for `qty`."
          },
          "aspect": {
            "type": "number",
            "description": "Client's view of the artwork width/height ratio. Fallback only \u2014\nthe server re-derives from the stored image when readable.\n"
          }
        }
      },
      "CreateProjectPayload": {
        "type": "object",
        "required": [
          "preset_id"
        ],
        "properties": {
          "preset_id": {
            "type": "integer",
            "description": "Matches `CanvasPreset.id` from `/sizes`"
          }
        }
      },
      "RenamePayload": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string"
          }
        }
      },
      "ImageStatus": {
        "type": "string",
        "enum": [
          "Uploading",
          "Processing",
          "Ready",
          "Error"
        ],
        "description": "Pipeline state. Frontend also synthesizes a `TooSmall` status for\nReady images whose decoded dimensions fall below a threshold \u2014\nthat value is **not** sent by the backend.\n"
      },
      "RenderStatus": {
        "type": "object",
        "required": [
          "status"
        ],
        "description": "Status of a project's server-side print render (see\n`POST/GET /projects/{id}/render`). `print_asset_url` is present only\nwhen `status` is `ready`; `error` only when `failed`.\n",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "idle",
              "rendering",
              "ready",
              "failed"
            ]
          },
          "print_asset_url": {
            "type": "string",
            "description": "Present when `status` is `ready` \u2014 the R2 print master URL."
          },
          "error": {
            "type": "string",
            "description": "Present when `status` is `failed`."
          }
        }
      },
      "SheetRenderStatus": {
        "type": "object",
        "required": [
          "sheet_id",
          "preset_id",
          "pack"
        ],
        "description": "One sheet's print master, if it has been rendered yet.",
        "properties": {
          "sheet_id": {
            "type": "string"
          },
          "preset_id": {
            "type": "integer",
            "description": "Gooten medium \u2014 matches `CanvasPreset.id` from `/sizes`"
          },
          "pack": {
            "type": "integer",
            "description": "How many identical copies of this sheet get printed"
          },
          "print_asset_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Null until this sheet has been rendered."
          },
          "state": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ProjectState"
              },
              {
                "type": "null"
              }
            ]
          }
        }
      },
      "RenderStatusDetail": {
        "type": "object",
        "required": [
          "status"
        ],
        "description": "`RenderStatus` plus the per-sheet breakdown. The top-level fields are\nunchanged, so readers written before sheets keep working.\n",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "idle",
              "rendering",
              "ready",
              "failed"
            ]
          },
          "print_asset_url": {
            "type": "string",
            "description": "Present when `status` is `ready`. Mirrors the **first** sheet's\nmaster; read `sheets` for the rest.\n"
          },
          "error": {
            "type": "string",
            "description": "Present when `status` is `failed`."
          },
          "sheets": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SheetRenderStatus"
            },
            "description": "Every sheet this project prints. A single-canvas project reports\none synthetic entry.\n"
          },
          "all_sheets_rendered": {
            "type": "boolean",
            "description": "True only when every sheet has a master. This \u2014 not\n`print_asset_url` \u2014 is what says an order is printable.\n"
          }
        }
      },
      "ImageAsset": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Bare key (UUID v4 generated at upload time)"
          },
          "status": {
            "$ref": "#/components/schemas/ImageStatus"
          },
          "original_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Deprecated / not persisted. The source upload lives only in\ntransient staging on disk during background removal; nothing\ndurable points at `/uploads/`. Always null on stored records \u2014\nrender `processed_url` instead.\n"
          },
          "processed_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cloudflare Images CDN URL after remove.bg has run. Drives\nthe visible image in the editor.\n"
          },
          "owner_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "deleted_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "hidden_at": {
            "type": [
              "string",
              "null"
            ],
            "description": "Per-asset hidden toggle (see `PATCH /me/assets/{id}`)"
          },
          "project_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The owning project, for a *project* image (deleted with its\nproject). Null for a *gallery* image \u2014 the user's reusable,\ntop-level copy, cleaned up independently. Gallery copies are\nde-duplicated per owner by `content_hash`.\n"
          },
          "content_hash": {
            "type": [
              "string",
              "null"
            ],
            "description": "SHA-256 (hex) of the processed PNG bytes. De-dup key for\ngallery copies, so uploading/claiming/re-using the same image\nnever creates a second gallery row for one user.\n"
          }
        }
      },
      "RecentRole": {
        "type": "string",
        "enum": [
          "Owner",
          "Collab",
          "Viewed"
        ],
        "description": "Derived. `Owner` if the project's `owner_id` matches the caller.\n`Collab` if the caller is in the project's collaborators list.\n`Viewed` if the caller has a Recents row but no membership.\n"
      },
      "RecentProject": {
        "type": "object",
        "required": [
          "project_id",
          "name",
          "canvas",
          "role",
          "last_accessed_at"
        ],
        "properties": {
          "project_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "canvas": {
            "$ref": "#/components/schemas/Canvas"
          },
          "role": {
            "$ref": "#/components/schemas/RecentRole"
          },
          "last_accessed_at": {
            "type": "string"
          },
          "hidden_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "deleted_at": {
            "type": [
              "string",
              "null"
            ],
            "description": "Carries forward from the underlying Project"
          },
          "canvas_updated_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "thumbnail_url": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "TouchedProject": {
        "type": "object",
        "required": [
          "project_id",
          "last_accessed_at"
        ],
        "properties": {
          "project_id": {
            "type": "string"
          },
          "last_accessed_at": {
            "type": "string"
          }
        }
      },
      "ClaimPayload": {
        "type": "object",
        "required": [
          "touched_projects",
          "uploaded_assets"
        ],
        "properties": {
          "touched_projects": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TouchedProject"
            },
            "description": "Projects the anonymous session interacted with"
          },
          "uploaded_assets": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Asset ids the anonymous session uploaded"
          }
        }
      },
      "Account": {
        "type": "object",
        "required": [
          "id",
          "created_at",
          "notifications"
        ],
        "properties": {
          "id": {
            "type": "string",
            "description": "Stytch user_id"
          },
          "display_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "avatar_url": {
            "type": [
              "string",
              "null"
            ],
            "description": "Cloudflare Images CDN URL"
          },
          "created_at": {
            "type": "string"
          },
          "updated_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "shipping_address": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ShippingAddress"
              },
              {
                "type": "null"
              }
            ],
            "description": "Where their stickers go by default. Absent until they give one; Stripe still collects the authoritative address at checkout.\n"
          },
          "notifications": {
            "$ref": "#/components/schemas/NotificationPrefs"
          }
        }
      },
      "ShippingAddress": {
        "type": "object",
        "description": "Every line is optional on purpose \u2014 a half-filled address is more useful than a refused one, and Stripe collects the copy that ships.\n",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "line1": {
            "type": [
              "string",
              "null"
            ]
          },
          "line2": {
            "type": [
              "string",
              "null"
            ]
          },
          "city": {
            "type": [
              "string",
              "null"
            ]
          },
          "state": {
            "type": [
              "string",
              "null"
            ]
          },
          "postal_code": {
            "type": [
              "string",
              "null"
            ]
          },
          "country": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO 3166-1 alpha-2, upper-case."
          }
        }
      },
      "NotificationPrefs": {
        "type": "object",
        "description": "Which emails the customer wants. The two that follow an order default on; marketing defaults off.\n",
        "required": [
          "order_shipped",
          "order_delivered",
          "promotions"
        ],
        "properties": {
          "order_shipped": {
            "type": "boolean",
            "default": true
          },
          "order_delivered": {
            "type": "boolean",
            "default": true
          },
          "promotions": {
            "type": "boolean",
            "default": false
          }
        }
      },
      "HiddenPatch": {
        "type": "object",
        "required": [
          "hidden"
        ],
        "properties": {
          "hidden": {
            "type": "boolean"
          }
        }
      },
      "AccountPatch": {
        "type": "object",
        "description": "A partial profile update: send only what changed. `shipping_address: null` deletes the stored address, while omitting the field leaves it alone.\n",
        "properties": {
          "display_name": {
            "type": "string"
          },
          "shipping_address": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/ShippingAddress"
              },
              {
                "type": "null"
              }
            ]
          },
          "notifications": {
            "$ref": "#/components/schemas/NotificationPrefs"
          }
        }
      }
    }
  }
}
