{
  "openapi": "3.1.0",
  "info": {
    "title": "EzyStudio public and Site API",
    "version": "0.0.1",
    "description": "Generated from the API’s routes and Vine validators by `api/tools/openapi/generate.ts`. Do not edit by hand: change api/tools/openapi/operations.ts and run `node --import @poppinss/ts-exec tools/openapi/generate.ts` in api/."
  },
  "servers": [
    {
      "url": "https://ezystudio.com",
      "description": "EzyStudio API"
    }
  ],
  "tags": [
    {
      "name": "Site API",
      "description": "What a deployed site’s server calls as the site, with its Site Key (`Authorization: Bearer <SITE_KEY>`). Server to server only: never from a browser."
    },
    {
      "name": "Public site",
      "description": "No credential. The site is the one whose address (Host) the request arrives on. Read only."
    },
    {
      "name": "App Store",
      "description": "The public App Store catalogue. No sign-in."
    },
    {
      "name": "Plans",
      "description": "Public plan catalogue (the pricing page)."
    },
    {
      "name": "Partners",
      "description": "Public partner directory and “Hire an expert” leads."
    },
    {
      "name": "Platform",
      "description": "Other public reads: brand, email assets, app media files, the visitor’s default language."
    }
  ],
  "paths": {
    "/api/site/v1/email-check": {
      "post": {
        "tags": [
          "Site API"
        ],
        "summary": "Check that an address is a real inbox",
        "description": "Before sign-up or an email change, the site asks whether an address can receive mail (no throwaway domain, a mail server exists). The answer is `{accept}`, never a validation error, so the site decides what to show.",
        "x-source": "site_api.controller.ts",
        "operationId": "postSiteEmailCheck",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              },
              "example": {
                "email": "maya@lumahair.test"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Whether to accept the address.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "accept": {
                              "type": "boolean"
                            }
                          }
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 200,
                  "data": {
                    "accept": true
                  }
                }
              }
            }
          },
          "401": {
            "description": "`E_NO_SITE_KEY`, `E_BAD_SITE_KEY`, `E_INVALID_SITE_KEY`, `E_SITE_KEY_REVOKED`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "POST /api/site/v1/email-check"
      }
    },
    "/api/site/v1/emails": {
      "post": {
        "tags": [
          "Site API"
        ],
        "summary": "Relay an account email",
        "description": "Ask EzyStudio to send one of the site’s account emails (sites cannot reach a mail provider). There is no HTML, subject or sender: the platform renders each kind with the site’s name and brand and builds the link on the site’s own address from `path`. `path` is required for `password_reset`, `verify_email` and `confirm_email_change`; `code` (6 digits) is required for `sign_in_code`; `subject` and `text` are required for `notification`, which may only go to one of the site’s owners (`SITE_OWNER_EMAILS`). The answer is the same whether the email went out or was dropped by the per-recipient limit.",
        "x-source": "site_api.controller.ts, site_email.service.ts",
        "operationId": "postSiteEmails",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "kind": {
                    "enum": [
                      "password_reset",
                      "verify_email",
                      "password_changed",
                      "account_exists",
                      "notification",
                      "confirm_email_change",
                      "email_changed",
                      "sign_in_code"
                    ],
                    "description": "`notification` is plain text to one of the site’s owners only."
                  },
                  "to": {
                    "type": "string",
                    "maxLength": 254,
                    "format": "email",
                    "description": "The recipient."
                  },
                  "path": {
                    "type": "string",
                    "maxLength": 512,
                    "pattern": "^\\/(?![/\\\\])[^\\s\\\\\\u0000-\\u001f\\u007f]*$",
                    "description": "Site-relative link (`/account/reset?token=…`): one leading `/`, never `//` or `\\`. Required for `password_reset`, `verify_email` and `confirm_email_change`."
                  },
                  "expiresInMinutes": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 10080,
                    "description": "Shown in the email (“this link expires in …”). Up to one week."
                  },
                  "subject": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 120,
                    "description": "Required for `notification`."
                  },
                  "text": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 2000,
                    "description": "Plain text, escaped when rendered. Required for `notification`."
                  },
                  "code": {
                    "type": "string",
                    "pattern": "^\\d{6}$",
                    "description": "`sign_in_code` only: exactly 6 digits, shown in the email."
                  }
                },
                "required": [
                  "kind"
                ]
              },
              "example": {
                "kind": "password_reset",
                "to": "maya@lumahair.test",
                "path": "/account/reset?token=8f2c…",
                "expiresInMinutes": 30
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted (queued, or dropped by the per-recipient limit: same answer).",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "accepted": {
                              "const": true
                            }
                          }
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 202,
                  "message": "Email accepted for delivery.",
                  "data": {
                    "accepted": true
                  }
                }
              }
            }
          },
          "401": {
            "description": "`E_NO_SITE_KEY` (no header), `E_BAD_SITE_KEY` (not a Site Key), `E_INVALID_SITE_KEY` (unknown, wrong secret, deleted site, inactive account, or another site’s host), `E_SITE_KEY_REVOKED`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "status": 401,
                  "errors": [
                    {
                      "message": "Invalid Site Key.",
                      "code": "E_INVALID_SITE_KEY"
                    }
                  ]
                }
              }
            }
          },
          "409": {
            "description": "`E_SITE_HAS_NO_HOST`: the site has no address to build the link on.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Validation failed, or `E_SITE_EMAIL_RECIPIENT` (a `notification` to someone other than the site owner).",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ValidationError"
                    },
                    {
                      "$ref": "#/components/schemas/Error"
                    }
                  ]
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "x-rate-limit": "600 calls a minute per site and key kind; 120 emails an hour per site and key kind; 1,000 emails and 100 notifications in any 24 hours per site; 5 an hour per recipient per site and 15 an hour per recipient across sites.",
        "x-adonis-route": "POST /api/site/v1/emails"
      }
    },
    "/api/site/v1/files": {
      "get": {
        "tags": [
          "Site API"
        ],
        "summary": "List files",
        "description": "Ready files of this site, newest first, for the key’s environment (live key: live files; editor key: staging files). Page with `before` = the `next` of the previous page.",
        "x-source": "site_files.service.ts `list`",
        "operationId": "getSiteFiles",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "parameters": [
          {
            "name": "before",
            "in": "query",
            "required": false,
            "description": "The `next` value of the previous page.",
            "schema": {
              "type": "string",
              "maxLength": 40
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "1 to 100. Default 50.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "`{files, next}`; `next` is null on the last page.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Site Key errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/site/v1/files"
      }
    },
    "/api/site/v1/files/{fileId}": {
      "get": {
        "tags": [
          "Site API"
        ],
        "summary": "Get one file",
        "description": "The file’s details (never its bytes: ask for a link).",
        "x-source": "site_files.service.ts `show`",
        "operationId": "getSiteFilesFileId",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "parameters": [
          {
            "name": "fileId",
            "in": "path",
            "required": true,
            "description": "The file id (`file_…`).",
            "schema": {
              "type": "string",
              "pattern": "^file_[0-9a-z]{26}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "`{file}`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Site Key errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`E_FILE_NOT_FOUND`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/site/v1/files/:fileId"
      }
    },
    "/api/site/v1/files/{fileId}/complete": {
      "post": {
        "tags": [
          "Site API"
        ],
        "summary": "Complete a file upload",
        "description": "Step 3 of 3, after the PUT: confirms the file is `ready` (the PUT already stored and checked it) and returns it; only ready files are listed and linkable. Call it from the backend after the browser says the upload finished, so your own records only point at stored files. A file still `pending` is checked in storage: exactly the declared size, or it is deleted (`E_FILE_SIZE_MISMATCH`). Not uploaded yet: 409, the grant stays until it expires (1 hour).",
        "x-source": "site_files.service.ts `complete`",
        "operationId": "postSiteFilesFileIdComplete",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "parameters": [
          {
            "name": "fileId",
            "in": "path",
            "required": true,
            "description": "The file id (`file_…`).",
            "schema": {
              "type": "string",
              "pattern": "^file_[0-9a-z]{26}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The file, now `ready`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Site Key errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`E_STORAGE_UNAVAILABLE`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`E_FILE_NOT_FOUND`: no such file on this site (and key kind).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`E_FILE_NOT_UPLOADED`: nothing stored yet. PUT the file, then call again.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "410": {
            "description": "`E_UPLOAD_EXPIRED`: the grant expired (1 hour). Ask for a new upload.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`E_FILE_SIZE_MISMATCH`: the stored file was not the declared size and was deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "POST /api/site/v1/files/:fileId/complete"
      }
    },
    "/api/site/v1/files/{fileId}/delete": {
      "post": {
        "tags": [
          "Site API"
        ],
        "summary": "Delete a file",
        "description": "Deletes the file and its stored bytes; it stops counting at once. Links already handed out stop working when they expire (at most the `expiresIn` they were made with).",
        "x-source": "site_files.service.ts `remove`",
        "operationId": "postSiteFilesFileIdDelete",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "parameters": [
          {
            "name": "fileId",
            "in": "path",
            "required": true,
            "description": "The file id (`file_…`).",
            "schema": {
              "type": "string",
              "pattern": "^file_[0-9a-z]{26}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "deleted": {
                              "const": true
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "Site Key errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`E_FILE_NOT_FOUND`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "POST /api/site/v1/files/:fileId/delete"
      }
    },
    "/api/site/v1/files/{fileId}/url": {
      "post": {
        "tags": [
          "Site API"
        ],
        "summary": "Get a signed link to a file",
        "description": "A link to a ready file that works until `expiresAt` (default 1 hour, 60 seconds to 7 days), then stops. Hand it to the browser to show or download the file. `download: true` makes the browser save it; images, PDF, audio, video and plain text are otherwise shown, everything else is always downloaded (SVG too). Ask for a new link each time a page needs one rather than storing links.",
        "x-source": "site_files.service.ts `signedUrl`",
        "operationId": "postSiteFilesFileIdUrl",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "parameters": [
          {
            "name": "fileId",
            "in": "path",
            "required": true,
            "description": "The file id (`file_…`).",
            "schema": {
              "type": "string",
              "pattern": "^file_[0-9a-z]{26}$"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "expiresIn": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 604800,
                    "description": "Seconds the link works: 60 to 604800 (7 days). Default 3600."
                  },
                  "download": {
                    "type": "boolean",
                    "description": "true = the browser saves the file instead of showing it."
                  }
                }
              },
              "example": {
                "expiresIn": 600
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The link.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "url": {
                              "type": "string"
                            },
                            "expiresAt": {
                              "type": "string",
                              "format": "date-time"
                            }
                          }
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 200,
                  "data": {
                    "url": "https://files.example/s42/live/01jbz3k8….pdf?token=…&expires=1793102400",
                    "expiresAt": "2026-10-27T10:10:00.000Z"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Site Key errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`E_STORAGE_UNAVAILABLE`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`E_FILE_NOT_FOUND`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`E_FILE_NOT_READY`: call complete first.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "POST /api/site/v1/files/:fileId/url"
      }
    },
    "/api/site/v1/files/uploads": {
      "post": {
        "tags": [
          "Site API"
        ],
        "summary": "Start a file upload",
        "description": "Step 1 of 3. The site’s backend asks for a short-lived upload URL for one file (step 1); the browser (or the backend) PUTs the bytes to it (step 2); the backend calls `complete` (step 3). `upload.url` is a path on the site’s OWN address (`/__platform/files/uploads/<token>`): the browser sends it to the same origin as the page, so the site’s CSP and any bucket need no change; from server code prefix it with `SITE_URL`. The platform counts every byte and stores exactly the declared size; the PUT answers `200 {file}` with the file `ready`, `413 E_BODY_TOO_LARGE` (larger than declared: nothing stored, the URL still works), `422 E_FILE_SIZE_MISMATCH` (shorter: nothing stored, ask again), `409 E_UPLOAD_TOKEN` (already used), `410 E_UPLOAD_EXPIRED`. Checks, in order: Storage is on for this site (the owner turns it on in the dashboard), the account has storage (an agency account needs its agency’s bucket), the type is on the allow-list (images, PDF, text, CSV, JSON, ZIP, audio, video, Office documents; never HTML or JavaScript), the size is 1 byte to 100 MB, and there is room. Storage is sold only as add-on packs: without one the answer is 402 `E_STORAGE_NEEDS_PACK`; a file that would not fit in the packs’ total is refused (402 `E_STORAGE_LIMIT`) before anything is stored, on every plan. On an agency’s own bucket the agency decides what its clients get. The file starts `pending` and counts against the limit until it is completed or the grant expires (1 hour). Send `upload.headers` exactly as given with the PUT; the URL works for 15 minutes and once. `name` is only a display name (any path is dropped); the storage key is built by the platform. Files are private unless `visibility` is `public`. The editor key works too: its files are separate (staging).",
        "x-source": "site_files.service.ts `createUpload`",
        "operationId": "postSiteFilesUploads",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 400,
                    "description": "Display name, e.g. `cv.pdf`. Paths and control characters are removed; up to 200 characters are kept."
                  },
                  "contentType": {
                    "type": "string",
                    "minLength": 3,
                    "maxLength": 127,
                    "description": "The file’s MIME type, e.g. `image/jpeg`. Must be on the allow-list."
                  },
                  "size": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Exact size in bytes. The stored file must match it exactly."
                  },
                  "visibility": {
                    "enum": [
                      "private",
                      "public"
                    ],
                    "description": "`private` (default): only signed links. `public`: also a stable link on the site’s own address, `/__platform/files/<id>/<name>`."
                  }
                },
                "required": [
                  "name",
                  "contentType",
                  "size"
                ]
              },
              "example": {
                "name": "cv.pdf",
                "contentType": "application/pdf",
                "size": 482113
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Granted: PUT the file’s bytes to `upload.url` (on the site’s own address) with `upload.headers`, then call complete.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 201,
                  "message": "Upload the file with a PUT to the URL, then call complete.",
                  "data": {
                    "file": {
                      "id": "file_01jbz3k8x2m4n6p8q0r2s4t6v8",
                      "name": "cv.pdf",
                      "contentType": "application/pdf",
                      "size": 482113,
                      "visibility": "private",
                      "status": "pending",
                      "environment": "production",
                      "createdAt": "2026-10-27T10:00:00.000Z",
                      "completedAt": null
                    },
                    "upload": {
                      "method": "PUT",
                      "url": "/__platform/files/uploads/eyJtZXNzYWdlIjoi…",
                      "headers": {
                        "Content-Type": "application/octet-stream"
                      },
                      "expiresAt": "2026-10-27T10:15:00.000Z"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "`E_NO_SITE_KEY`, `E_BAD_SITE_KEY`, `E_INVALID_SITE_KEY`, `E_SITE_KEY_REVOKED`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "`E_STORAGE_NEEDS_PACK`: no storage pack (the owner adds one in the dashboard). `E_STORAGE_LIMIT`: the file does not fit in the storage left (delete files or add a bigger pack). Also the account’s usage hold.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "status": 402,
                  "errors": [
                    {
                      "message": "There isn’t room for this file: 4.9 GB of 5 GB used. Delete files or add storage in the dashboard.",
                      "code": "E_STORAGE_LIMIT"
                    }
                  ]
                }
              }
            }
          },
          "403": {
            "description": "`E_STORAGE_OFF` (turned off for this site) or `E_STORAGE_UNAVAILABLE` (no storage for this account).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`E_FILE_TYPE`, `E_FILE_EMPTY`, `E_FILE_TOO_LARGE` (over 100 MB), or validation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "The per-site rate limit, or too many failed keys from this IP.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-rate-limit": "300 upload grants a minute per site; 600 calls a minute per site and key kind.",
        "x-adonis-route": "POST /api/site/v1/files/uploads"
      }
    },
    "/api/site/v1/files/usage": {
      "get": {
        "tags": [
          "Site API"
        ],
        "summary": "Storage used and the limit",
        "description": "The account’s uploaded files against its storage packs (all its apps together). `limitBytes` 0 = no pack, null = no limit (possible on an agency’s bucket). `onExceed` is `block`: uploads stop at the limit. Show it before a large upload to tell the visitor early.",
        "x-source": "site_files.service.ts `usage`",
        "operationId": "getSiteFilesUsage",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Usage.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 200,
                  "data": {
                    "usedBytes": 1288490188,
                    "limitBytes": 5368709120,
                    "onExceed": "block",
                    "maxFileBytes": 104857600
                  }
                }
              }
            }
          },
          "401": {
            "description": "Site Key errors.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`E_STORAGE_OFF` or `E_STORAGE_UNAVAILABLE`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/site/v1/files/usage"
      }
    },
    "/api/site/v1/jobs": {
      "post": {
        "tags": [
          "Site API"
        ],
        "summary": "Send background jobs",
        "description": "Send one job (`{list, data, delay | at, key, label, group}`) or up to 100 (`{jobs: [...]}`). EzyStudio keeps each job and delivers it when due to the site’s own `POST /site-api/_platform/jobs` (body `{id, list, attempt, maxAttempts, scheduledFor, group, data}`), signed (`x-site-signature: t=<unix>,v1=<HMAC-SHA256(SITE_JOBS_SECRET, \"<t>.<body>\")>`), within about 1–2 seconds when due now. The site answers 2xx (done), 5xx / 408 / 429 / no answer within 60 s (tried again after 1 min, 2 min, 10 min, 1 h, honouring `Retry-After`), another 4xx (failed now, its `{error}` shown to the owner), or 404 `{code: \"no_handler\"}`. `key` dedupes for 24 h (a second send returns the first job, `deduplicated: true`). `group` matters on ordered lists (`background.json` `\"ordered\": true`): one job at a time per group, in send order. Live key only: an editor key gets 403 `E_JOBS_EDITOR_KEY` (the editor runs jobs in its own process).",
        "x-source": "site_jobs.service.ts, site_jobs.dispatcher.ts",
        "operationId": "postSiteJobs",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              },
              "example": {
                "list": "booking-reminders",
                "data": {
                  "bookingId": 42
                },
                "at": "2026-11-01T09:00:00.000Z",
                "key": "remind-42",
                "label": "Reminder · Cut, Sat 10:00",
                "group": "customer-9"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted. One job → the job; a batch → `{jobs: [...]}`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 202,
                  "message": "Job accepted.",
                  "data": {
                    "id": "job_abcdefghijklmnopqrstuvwxyz",
                    "list": "booking-reminders",
                    "runAt": "2026-11-01T09:00:00.000Z",
                    "deduplicated": false
                  }
                }
              }
            }
          },
          "401": {
            "description": "`E_NO_SITE_KEY`, `E_BAD_SITE_KEY`, `E_INVALID_SITE_KEY`, `E_SITE_KEY_REVOKED`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`E_JOBS_EDITOR_KEY`: jobs are not sent from the editor.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`E_JOBS_NOT_AN_APP`: a website (static) has no background jobs.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "`E_JOBS_PAYLOAD_TOO_LARGE`: data over 32 KB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "`E_JOBS_BAD_LIST`, `E_JOBS_BAD_TIME`, `E_JOBS_TOO_FAR` (over 1 year), `E_JOBS_LABEL`, `E_JOBS_KEY`, `E_JOBS_GROUP`, `E_JOBS_BATCH_TOO_LARGE`, or validation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "`E_JOBS_BACKLOG` (the list has 50,000 waiting), the per-site rate limit, or too many failed keys from this IP.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-rate-limit": "600 calls a minute per site and key kind; 50,000 waiting jobs per list.",
        "x-adonis-route": "POST /api/site/v1/jobs"
      }
    },
    "/api/site/v1/jobs/cancel": {
      "post": {
        "tags": [
          "Site API"
        ],
        "summary": "Cancel a waiting job",
        "description": "Cancel a job that has not run yet, by `{id}` or by the `{list, key}` it was sent with. An already cancelled job answers `cancelled: true`; one that is running or finished answers 409.",
        "x-source": "site_jobs.service.ts",
        "operationId": "postSiteJobsCancel",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              },
              "example": {
                "list": "booking-reminders",
                "key": "remind-42"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "`cancelled`: false when no such job exists.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "cancelled": {
                              "type": "boolean"
                            }
                          }
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 200,
                  "message": "Cancelled. It won't run.",
                  "data": {
                    "cancelled": true
                  }
                }
              }
            }
          },
          "401": {
            "description": "`E_NO_SITE_KEY`, `E_BAD_SITE_KEY`, `E_INVALID_SITE_KEY`, `E_SITE_KEY_REVOKED`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "`E_JOBS_EDITOR_KEY`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`E_JOBS_NOT_CANCELLABLE`: it is running or finished.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "POST /api/site/v1/jobs/cancel"
      }
    },
    "/api/site/v1/owner-sign-in": {
      "post": {
        "tags": [
          "Site API"
        ],
        "summary": "Spend a one-click owner sign-in ticket",
        "description": "The site’s server spends the one-use, 2-minute ticket from “Log in to live admin” (the live key) or the editor Preview’s portal button (the editor key), and gets the owner address to sign in, with the current owner list. A ticket only works for its own site and for the key kind it was made for.",
        "x-source": "owner_sign_in.controller.ts, owner_sign_in.service.ts",
        "operationId": "postSiteOwnerSignIn",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "ticket"
                ],
                "properties": {
                  "ticket": {
                    "type": "string"
                  },
                  "version": {
                    "type": "integer",
                    "enum": [
                      2
                    ]
                  }
                }
              },
              "example": {
                "ticket": "Qm9vay1hLXRpY2tldC1mb3ItdGhlLW93bmVyLXNpZ25pbg",
                "version": 2
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "An opaque id for the account owner. Never an email or name.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "ownerId": {
                              "type": "string"
                            }
                          }
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 200,
                  "data": {
                    "ownerId": "b3duZXItaWQtZm9yLXRoaXMtc2l0ZS0wMDAwMDAwMDAwMA"
                  }
                }
              }
            }
          },
          "401": {
            "description": "`E_NO_SITE_KEY`, `E_BAD_SITE_KEY`, `E_INVALID_SITE_KEY`, `E_SITE_KEY_REVOKED`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`E_OWNER_TICKET_INVALID`: unknown, spent, expired, another site’s, for the other key kind, or no longer an owner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "POST /api/site/v1/owner-sign-in"
      }
    },
    "/api/site/v1/payments": {
      "get": {
        "tags": [
          "Site API"
        ],
        "summary": "The site’s prices",
        "description": "The mode (`test` or `live`, decided by the key and the account, never the site), whether the site can take payments, and its active prices.",
        "operationId": "getSitePayments",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The catalogue.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 200,
                  "data": {
                    "mode": "test",
                    "ready": true,
                    "prices": [
                      {
                        "key": "pro-monthly",
                        "name": "Pro",
                        "description": null,
                        "amountCents": 1500,
                        "currency": "USD",
                        "interval": "month",
                        "intervalCount": 1,
                        "trialDays": 7
                      }
                    ]
                  }
                }
              }
            }
          },
          "401": {
            "description": "The Site Key is missing or wrong.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": "E_NO_SITE_KEY"
              }
            }
          }
        },
        "x-adonis-route": "GET /api/site/v1/payments"
      }
    },
    "/api/site/v1/payments/checkout": {
      "post": {
        "tags": [
          "Site API"
        ],
        "summary": "Start a checkout",
        "description": "Starts a checkout for one price and answers how the buyer pays (`embedded`). The amount is the catalogue’s. Send an `idempotencyKey`, one per purchase attempt and the same on every retry: the same key answers the same checkout, or `{id, paid: true, embedded: null}` once it’s paid, so a double click or a retry never starts a second one. A declined or expired checkout frees the key. 120 a minute per site and key kind.",
        "operationId": "postSitePaymentsCheckout",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "price": {
                    "type": "string",
                    "pattern": "^[a-z0-9][a-z0-9-]{0,62}$"
                  },
                  "quantity": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 99
                  },
                  "email": {
                    "type": "string",
                    "maxLength": 191,
                    "format": "email"
                  },
                  "name": {
                    "type": "string",
                    "maxLength": 191
                  },
                  "successPath": {
                    "type": "string",
                    "maxLength": 400,
                    "pattern": "^\\/(?![/\\\\])[^\\s\\\\\\u0000-\\u001f\\u007f]*$"
                  },
                  "cancelPath": {
                    "type": "string",
                    "maxLength": 400,
                    "pattern": "^\\/(?![/\\\\])[^\\s\\\\\\u0000-\\u001f\\u007f]*$"
                  },
                  "metadata": {
                    "type": "object",
                    "additionalProperties": {
                      "type": "string",
                      "maxLength": 500
                    }
                  },
                  "customerRef": {
                    "type": "string",
                    "maxLength": 128
                  },
                  "idempotencyKey": {
                    "type": "string",
                    "pattern": "^[A-Za-z0-9_-]{16,64}$"
                  },
                  "gateway": {
                    "type": "string",
                    "pattern": "^[a-z][a-z0-9_-]{1,39}$"
                  },
                  "noTrial": {
                    "type": "boolean"
                  }
                },
                "required": [
                  "price",
                  "successPath",
                  "cancelPath"
                ]
              },
              "example": {
                "price": "pro-monthly",
                "successPath": "/thanks/",
                "cancelPath": "/pricing/",
                "idempotencyKey": "attempt_0123456789abcdef"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "`{id, mode, embedded}`, or `{id, mode, embedded: null, paid: true}`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "No such price.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`E_SITE_PAYMENTS_IN_PROGRESS` (the first request is still starting: keep the key and try again), `E_SITE_PAYMENTS_IDEMPOTENCY_CONFLICT` (the key was used for a different purchase), `E_SITE_PAYMENTS_TRIAL_USED` (ask the buyer, then `noTrial: true`) or `E_PAYMENTS_NOT_READY`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "A field is not valid, or `E_SITE_PAYMENTS_EMAIL_REQUIRED`, `E_SITE_PAYMENTS_GATEWAY_UNAVAILABLE`, `E_SITE_PAYMENTS_QUANTITY`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "`E_SITE_PAYMENTS_UNAVAILABLE`: the same key again is safe.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "POST /api/site/v1/payments/checkout"
      }
    },
    "/api/site/v1/payments/checkout/{id}": {
      "get": {
        "tags": [
          "Site API"
        ],
        "summary": "What happened to a checkout",
        "description": "Status, amount, buyer and subscription. While it’s open, the payment is re-read from the provider, so `paid` shows at once. An editor key reads test checkouts only.",
        "operationId": "getSitePaymentsCheckoutId",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The checkout id (`pcs_…`).",
            "schema": {
              "type": "string",
              "pattern": "^pcs_[a-f0-9]{24}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The checkout.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "The Site Key is missing or wrong.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": "E_NO_SITE_KEY"
              }
            }
          },
          "404": {
            "description": "No such checkout.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/site/v1/payments/checkout/:id"
      }
    },
    "/api/site/v1/payments/checkout/gateways": {
      "get": {
        "tags": [
          "Site API"
        ],
        "summary": "The ways to pay a price",
        "description": "The payment providers the buyer may choose for this price: active, take its currency, and its free trial when it has one.",
        "operationId": "getSitePaymentsCheckoutGateways",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "parameters": [
          {
            "name": "price",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[a-z0-9][a-z0-9-]{0,62}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "`{mode, gateways: [{slug, name}]}`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "The Site Key is missing or wrong.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": "E_NO_SITE_KEY"
              }
            }
          },
          "404": {
            "description": "No such price.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/site/v1/payments/checkout/gateways"
      }
    },
    "/api/site/v1/payments/currencies": {
      "get": {
        "tags": [
          "Site API"
        ],
        "summary": "The currencies this site can charge in",
        "description": "The currencies a new price may use, from the site’s active payment providers.",
        "operationId": "getSitePaymentsCurrencies",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The currencies.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "The Site Key is missing or wrong.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": "E_NO_SITE_KEY"
              }
            }
          }
        },
        "x-adonis-route": "GET /api/site/v1/payments/currencies"
      }
    },
    "/api/site/v1/payments/memberships": {
      "get": {
        "tags": [
          "Site API"
        ],
        "summary": "A buyer’s memberships",
        "description": "The subscriptions bought with this email. Access follows `accessEndsAt` (`hasAccess`), never `status`. Show them only to that buyer, once their email is verified.",
        "operationId": "getSitePaymentsMemberships",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "parameters": [
          {
            "name": "email",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "maxLength": 191,
              "format": "email"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "`{mode, memberships}`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "The Site Key is missing or wrong.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": "E_NO_SITE_KEY"
              }
            }
          }
        },
        "x-adonis-route": "GET /api/site/v1/payments/memberships"
      }
    },
    "/api/site/v1/payments/memberships/{id}/cancel": {
      "post": {
        "tags": [
          "Site API"
        ],
        "summary": "Cancel a membership",
        "description": "For the site’s own cancel button. `atPeriodEnd` keeps access until the period ends.",
        "operationId": "postSitePaymentsMembershipsIdCancel",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The membership: its subscription id, or our `mem_…` id.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "atPeriodEnd": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "`{membership}`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "The Site Key is missing or wrong.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": "E_NO_SITE_KEY"
              }
            }
          },
          "404": {
            "description": "No such membership.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "POST /api/site/v1/payments/memberships/:id/cancel"
      }
    },
    "/api/site/v1/payments/prices": {
      "post": {
        "tags": [
          "Site API"
        ],
        "summary": "Add a price",
        "description": "Editor key only. The same key with the same terms answers 200 with `created: false`. 60 an hour per site.",
        "operationId": "postSitePaymentsPrices",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "key": {
                    "type": "string",
                    "pattern": "^[a-z0-9][a-z0-9-]{0,62}$"
                  },
                  "name": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 120
                  },
                  "description": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "maxLength": 500
                  },
                  "amountCents": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 99999999
                  },
                  "currency": {
                    "type": "string",
                    "pattern": "^[A-Za-z]{3}$"
                  },
                  "interval": {
                    "enum": [
                      "one_time",
                      "month",
                      "year"
                    ]
                  },
                  "intervalCount": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 12
                  },
                  "trialDays": {
                    "type": "integer",
                    "minimum": 0,
                    "maximum": 365
                  }
                },
                "required": [
                  "key",
                  "name",
                  "amountCents",
                  "currency",
                  "interval"
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "That price already exists: `{price, created: false}`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "201": {
            "description": "`{price, created: true}`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "The Site Key is missing or wrong.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": "E_NO_SITE_KEY"
              }
            }
          },
          "403": {
            "description": "`E_SITE_PAYMENTS_EDITOR_ONLY`: a price is added with the editor key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`E_PRICE_KEY_TAKEN`: the key exists with other terms, or the owner archived it.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "A field is not valid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "POST /api/site/v1/payments/prices"
      }
    },
    "/api/site/v1/plans": {
      "get": {
        "tags": [
          "Site API"
        ],
        "summary": "The agency’s client plans (house website)",
        "description": "The plans an agency sells to its clients, for its own marketing website. Any other site gets 404. Editor or live key; never cached.",
        "operationId": "getSitePlans",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The plans.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "description": "The Site Key is missing or wrong.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": "E_NO_SITE_KEY"
              }
            }
          },
          "404": {
            "description": "Not an agency’s house website.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/site/v1/plans"
      }
    },
    "/api/site/v1/template/download": {
      "get": {
        "tags": [
          "Site API"
        ],
        "summary": "Download this site’s template",
        "description": "The template this site is built from, at its current version, as a zip. It’s how a site upgrades: unzip it and run `npm run platform -- upgrade --from <folder>`. It holds no secrets.",
        "operationId": "getSiteTemplateDownload",
        "security": [
          {
            "siteKey": []
          }
        ],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The template as a zip."
          },
          "401": {
            "description": "The Site Key is missing or wrong.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": "E_NO_SITE_KEY"
              }
            }
          }
        },
        "x-adonis-route": "GET /api/site/v1/template/download"
      }
    },
    "/api/public/site/v1/agent-docs": {
      "get": {
        "tags": [
          "Public site"
        ],
        "summary": "Get the platform rules for coding agents",
        "description": "Markdown, not JSON: the core rules every AI agent follows in an EzyStudio site (it lists the topic docs). The template is `?template=`, else the template of the site on this host, else `astro-app`. Cached 5 minutes with an ETag (`If-None-Match` → 304).",
        "x-source": "agent_docs.controller.ts",
        "operationId": "getPublicSiteAgentDocs",
        "security": [],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "parameters": [
          {
            "name": "template",
            "in": "query",
            "required": false,
            "description": "`astro-app` or `astro-static`. Anything else answers 404.",
            "schema": {
              "type": "string",
              "maxLength": 32
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The core doc (`X-Agent-Docs-Template` names the template).",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                },
                "example": "# Site platform rules (vf2297aa7)\n\n# Rules for AI agents working in this app\n…"
              }
            }
          },
          "304": {
            "description": "Not modified (ETag matched)."
          },
          "404": {
            "description": "Unknown template.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "`E_AGENT_DOCS_UNAVAILABLE`: the docs could not be rendered.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-rate-limit": "300 a minute per caller.",
        "x-adonis-route": "GET /api/public/site/v1/agent-docs"
      }
    },
    "/api/public/site/v1/agent-docs/{topic}": {
      "get": {
        "tags": [
          "Public site"
        ],
        "summary": "Get one agent topic doc",
        "description": "Markdown for one topic, e.g. `site-api`, `security`, `auth`, `database` (`.md` optional; `core` is the core doc).",
        "x-source": "agent_docs.controller.ts, templates/<template>/docs/agent/",
        "operationId": "getPublicSiteAgentDocsTopic",
        "security": [],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "parameters": [
          {
            "name": "topic",
            "in": "path",
            "required": true,
            "description": "Topic name, lowercase letters, digits and dashes, optional `.md`.",
            "schema": {
              "type": "string",
              "pattern": "^[a-z0-9][a-z0-9-]{0,63}(\\.md)?$"
            }
          },
          {
            "name": "template",
            "in": "query",
            "required": false,
            "description": "`astro-app` or `astro-static`.",
            "schema": {
              "type": "string",
              "maxLength": 32
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The topic doc.",
            "content": {
              "text/markdown": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "304": {
            "description": "Not modified (ETag matched)."
          },
          "404": {
            "description": "`E_AGENT_DOC_NOT_FOUND`: no such topic for this template.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "status": 404,
                  "errors": [
                    {
                      "message": "No such agent doc.",
                      "code": "E_AGENT_DOC_NOT_FOUND"
                    }
                  ]
                }
              }
            }
          },
          "503": {
            "description": "`E_AGENT_DOCS_UNAVAILABLE`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-rate-limit": "300 a minute per caller.",
        "x-adonis-route": "GET /api/public/site/v1/agent-docs/:topic"
      }
    },
    "/api/public/site/v1/initial-data": {
      "get": {
        "tags": [
          "Public site"
        ],
        "summary": "Get the site’s public brand (template path)",
        "description": "Same answer as `GET /api/public/site/v1/site`. This is the path the EzyStudio templates call.",
        "x-source": "site_public.controller.ts",
        "operationId": "getPublicSiteInitialData",
        "security": [],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The site.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/PublicSite"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "No site is served on this host.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-rate-limit": "300 a minute per caller.",
        "x-adonis-route": "GET /api/public/site/v1/initial-data"
      }
    },
    "/api/public/site/v1/payments": {
      "get": {
        "tags": [
          "Public site"
        ],
        "summary": "The site’s prices, for its pages",
        "description": "The catalogue for a site’s Buy buttons. No credential: the site comes from the address it’s called on.",
        "operationId": "getPublicSitePayments",
        "security": [],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The catalogue.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "No published site on this address.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/site/v1/payments"
      }
    },
    "/api/public/site/v1/payments/checkout": {
      "post": {
        "tags": [
          "Public site"
        ],
        "summary": "Start a checkout from a page",
        "description": "Starts a checkout for one price and answers how the buyer pays (`embedded`). The amount is the catalogue’s. Send an `idempotencyKey`, one per purchase attempt and the same on every retry: the same key answers the same checkout, or `{id, paid: true, embedded: null}` once it’s paid, so a double click or a retry never starts a second one. A declined or expired checkout frees the key. For a static site’s Buy button: no credential, the site comes from the address.",
        "operationId": "postPublicSitePaymentsCheckout",
        "security": [],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "price": {
                    "type": "string",
                    "pattern": "^[a-z0-9][a-z0-9-]{0,62}$"
                  },
                  "quantity": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 99
                  },
                  "successPath": {
                    "type": "string",
                    "maxLength": 400,
                    "pattern": "^\\/(?![/\\\\])[^\\s\\\\\\u0000-\\u001f\\u007f]*$"
                  },
                  "cancelPath": {
                    "type": "string",
                    "maxLength": 400,
                    "pattern": "^\\/(?![/\\\\])[^\\s\\\\\\u0000-\\u001f\\u007f]*$"
                  },
                  "email": {
                    "type": "string",
                    "maxLength": 191,
                    "format": "email"
                  },
                  "idempotencyKey": {
                    "type": "string",
                    "pattern": "^[A-Za-z0-9_-]{16,64}$"
                  },
                  "gateway": {
                    "type": "string",
                    "pattern": "^[a-z][a-z0-9_-]{1,39}$"
                  },
                  "noTrial": {
                    "type": "boolean"
                  }
                },
                "required": [
                  "price",
                  "successPath",
                  "cancelPath"
                ]
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "`{id, mode, embedded}`, or `{id, mode, embedded: null, paid: true}`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "No such price.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "`E_SITE_PAYMENTS_IN_PROGRESS` (the first request is still starting: keep the key and try again), `E_SITE_PAYMENTS_IDEMPOTENCY_CONFLICT` (the key was used for a different purchase), `E_SITE_PAYMENTS_TRIAL_USED` (ask the buyer, then `noTrial: true`) or `E_PAYMENTS_NOT_READY`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "A field is not valid, or `E_SITE_PAYMENTS_EMAIL_REQUIRED`, `E_SITE_PAYMENTS_GATEWAY_UNAVAILABLE`, `E_SITE_PAYMENTS_QUANTITY`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "502": {
            "description": "`E_SITE_PAYMENTS_UNAVAILABLE`: the same key again is safe.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "POST /api/public/site/v1/payments/checkout"
      }
    },
    "/api/public/site/v1/payments/checkout/{id}": {
      "get": {
        "tags": [
          "Public site"
        ],
        "summary": "A checkout’s status",
        "description": "`{status}` only (`open`, `paid`, `failed` or `expired`), what the Buy button checks while the payment window is open. Nothing else about the buyer is shown.",
        "operationId": "getPublicSitePaymentsCheckoutId",
        "security": [],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The checkout id (`pcs_…`).",
            "schema": {
              "type": "string",
              "pattern": "^pcs_[a-f0-9]{24}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "`{status}`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "No such checkout on this site.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/site/v1/payments/checkout/:id"
      }
    },
    "/api/public/site/v1/payments/gateways": {
      "get": {
        "tags": [
          "Public site"
        ],
        "summary": "The ways to pay a price, for its pages",
        "description": "As the Site API’s, for a static site’s Buy button.",
        "operationId": "getPublicSitePaymentsGateways",
        "security": [],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "parameters": [
          {
            "name": "price",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "pattern": "^[a-z0-9][a-z0-9-]{0,62}$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "`{mode, gateways: [{slug, name}]}`.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "No published site on this address, or no such price.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/site/v1/payments/gateways"
      }
    },
    "/api/public/site/v1/plans": {
      "get": {
        "tags": [
          "Public site"
        ],
        "summary": "Get the plans an agency sells, on its own website",
        "description": "On an agency's own marketing website (its house site): the plans and add-ons it sells, the same catalogue as `GET /api/public/plans` on its dashboard address, plus `signupUrl` (where Start / Buy buttons send visitors: `{signupUrl}?plan=<code>&interval=month|year|lifetime`) and `checkoutOpen` (false: the agency can't take payments yet, `opensLaterReason` `payments_not_connected` | `agency_trial`; show paid plans as coming soon, a $0 price stays startable while payments aren't connected). Same origin, so no CORS, key or cookies. Any other site: 404. `Cache-Control: no-cache` + ETag.",
        "x-source": "site_public.controller.ts",
        "operationId": "getPublicSitePlans",
        "security": [],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The catalogue.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "audience": {
                              "type": "string"
                            },
                            "signupUrl": {
                              "type": "string"
                            },
                            "checkoutOpen": {
                              "type": "boolean"
                            },
                            "opensLaterReason": {
                              "type": "string",
                              "nullable": true
                            },
                            "plans": {
                              "type": "array"
                            },
                            "addons": {
                              "type": "array"
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "Not an agency's own website.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "status": 404,
                  "errors": [
                    {
                      "message": "Not found.",
                      "code": "E_NOT_FOUND"
                    }
                  ]
                }
              }
            }
          }
        },
        "x-rate-limit": "300 a minute per caller.",
        "x-adonis-route": "GET /api/public/site/v1/plans"
      }
    },
    "/api/public/site/v1/site": {
      "get": {
        "tags": [
          "Public site"
        ],
        "summary": "Get the site’s public brand",
        "description": "The site whose address this request arrived on: name, address, logo, favicon and brand colour. Cached for 60 seconds (`Cache-Control: public, max-age=60`, `Vary: Host`).",
        "x-source": "site_public.controller.ts",
        "operationId": "getPublicSiteSite",
        "security": [],
        "servers": [
          {
            "url": "{siteUrl}",
            "description": "The site’s own address (`SITE_URL`): EzyStudio answers `/api/*` there.",
            "variables": {
              "siteUrl": {
                "default": "https://your-site.ezystudio.app"
              }
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The site.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "$ref": "#/components/schemas/PublicSite"
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 200,
                  "data": {
                    "id": 5,
                    "name": "Luma Hair",
                    "slug": "luma-hair",
                    "kind": "app",
                    "template": "astro-app",
                    "url": "https://luma-hair.ezystudio.app",
                    "logo": null,
                    "favIcon": null,
                    "brandColor": null
                  }
                }
              }
            }
          },
          "404": {
            "description": "No site is served on this host.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "status": 404,
                  "errors": [
                    {
                      "message": "No site is served on this host.",
                      "code": "E_SITE_NOT_FOUND"
                    }
                  ]
                }
              }
            }
          }
        },
        "x-rate-limit": "300 a minute per caller.",
        "x-adonis-route": "GET /api/public/site/v1/site"
      }
    },
    "/api/public/store/v1/apps": {
      "get": {
        "tags": [
          "App Store"
        ],
        "summary": "Browse or search apps",
        "description": "Live apps only. `q` searches names and descriptions (unmatched searches are counted as requests for new apps).",
        "x-source": "app_store.controller.ts `apps`",
        "operationId": "getPublicStoreApps",
        "security": [],
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": false,
            "description": "Search words.",
            "schema": {
              "type": "string",
              "maxLength": 120
            }
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "A category slug.",
            "schema": {
              "type": "string",
              "maxLength": 48,
              "pattern": "^[a-z0-9-]+$"
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "`meta.nextCursor` from the previous page.",
            "schema": {
              "type": "string",
              "maxLength": 512
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 60
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of app cards.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "data": {
                              "type": "array"
                            },
                            "meta": {
                              "allOf": [
                                {
                                  "$ref": "#/components/schemas/CursorMeta"
                                },
                                {
                                  "type": "object",
                                  "properties": {
                                    "total": {
                                      "type": "integer",
                                      "description": "Browse only, first page (no `cursor`): how many live apps the browse matches in all."
                                    }
                                  }
                                }
                              ]
                            }
                          }
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 200,
                  "data": {
                    "data": [
                      {
                        "id": 1,
                        "slug": "salon-booking",
                        "name": "Salon Booking",
                        "kind": "app",
                        "tagline": "Stylists, time slots, deposits and text reminders.",
                        "category": {
                          "slug": "bookings",
                          "name": "Bookings",
                          "tone": "bookings"
                        },
                        "developer": {
                          "name": "EzyStudio",
                          "slug": "ezystudio"
                        },
                        "icon": {
                          "url": "https://ezystudio.com/api/public/app-media/v1/apps/1/demo/icon.svg",
                          "alt": "Salon Booking icon"
                        },
                        "pricing": {
                          "model": "included",
                          "priceCents": null,
                          "currency": "USD"
                        },
                        "rating": {
                          "average": 4.4,
                          "count": 7
                        },
                        "installsCount": 44451,
                        "isFeatured": true,
                        "isFirstParty": true
                      }
                    ],
                    "meta": {
                      "nextCursor": "eyJpZCI6MX0",
                      "hasNextPage": true,
                      "query": null,
                      "total": 18
                    }
                  }
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        },
        "x-adonis-route": "GET /api/public/store/v1/apps"
      }
    },
    "/api/public/store/v1/apps/{slug}": {
      "get": {
        "tags": [
          "App Store"
        ],
        "summary": "Get a listing",
        "description": "Everything on a listing page: the card fields plus version, description, data note, support email, customization, included, features, setup steps, made for, try saying, demo URL, compatible plans and media.",
        "x-source": "app_store.service.ts `detail`",
        "operationId": "getPublicStoreAppsSlug",
        "security": [],
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The listing slug, e.g. `salon-booking`.",
            "schema": {
              "type": "string",
              "maxLength": 96,
              "pattern": "^[a-z0-9][a-z0-9-]*$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The listing.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "app": {
                              "type": "object"
                            },
                            "media": {
                              "type": "array"
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "No live app with that slug.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "status": 404,
                  "errors": [
                    {
                      "message": "We couldn't find that app.",
                      "code": "APP_NOT_FOUND"
                    }
                  ]
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/store/v1/apps/:slug"
      }
    },
    "/api/public/store/v1/apps/{slug}/reviews": {
      "get": {
        "tags": [
          "App Store"
        ],
        "summary": "List a listing’s reviews",
        "description": "Published reviews from verified buyers, with the developer’s reply.",
        "x-source": "app_reviews.service.ts `listPublic`",
        "operationId": "getPublicStoreAppsSlugReviews",
        "security": [],
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "The listing slug, e.g. `salon-booking`.",
            "schema": {
              "type": "string",
              "maxLength": 96,
              "pattern": "^[a-z0-9][a-z0-9-]*$"
            }
          },
          {
            "name": "sort",
            "in": "query",
            "required": false,
            "description": "`recent` (default), `highest` or `lowest`.",
            "schema": {
              "enum": [
                "recent",
                "highest",
                "lowest"
              ]
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "description": "`meta.nextCursor`.",
            "schema": {
              "type": "string",
              "maxLength": 512
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Page size.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of reviews.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "data": {
                              "type": "array"
                            },
                            "meta": {
                              "type": "object"
                            }
                          }
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 200,
                  "data": {
                    "data": [
                      {
                        "id": 1,
                        "rating": 5,
                        "body": "Since we started taking a $10 deposit, no-shows have almost stopped.",
                        "authorName": "Maya",
                        "businessName": "Luma Hair",
                        "city": "Portland",
                        "developerReply": null,
                        "repliedAt": null,
                        "createdAt": "2026-09-18T12:00:00.000Z"
                      }
                    ],
                    "meta": {
                      "sort": "recent",
                      "nextCursor": null,
                      "hasNextPage": false
                    }
                  }
                }
              }
            }
          },
          "404": {
            "description": "No live app with that slug.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        },
        "x-adonis-route": "GET /api/public/store/v1/apps/:slug/reviews"
      }
    },
    "/api/public/store/v1/categories": {
      "get": {
        "tags": [
          "App Store"
        ],
        "summary": "List categories",
        "description": "Store categories with live app counts. Cached 60 seconds with an ETag.",
        "x-source": "app_store.controller.ts",
        "operationId": "getPublicStoreCategories",
        "security": [],
        "responses": {
          "200": {
            "description": "Categories.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "type": "object"
                          }
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 200,
                  "data": [
                    {
                      "slug": "bookings",
                      "name": "Bookings",
                      "icon": "calendar-check",
                      "tone": "bookings",
                      "appsCount": 8
                    }
                  ]
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/store/v1/categories"
      }
    },
    "/api/public/store/v1/collections": {
      "get": {
        "tags": [
          "App Store"
        ],
        "summary": "List collections",
        "description": "The store’s collections (a title, a subtitle and the category they show).",
        "x-source": "app_store.constants.ts",
        "operationId": "getPublicStoreCollections",
        "security": [],
        "responses": {
          "200": {
            "description": "Collections.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "array",
                          "items": {
                            "type": "object"
                          }
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 200,
                  "data": [
                    {
                      "key": "bookings",
                      "categorySlug": "bookings",
                      "icon": "calendar-days",
                      "tone": "a",
                      "title": "Take bookings",
                      "subtitle": "Salons, clinics, spas, tutors, hotels and more"
                    }
                  ]
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/store/v1/collections"
      }
    },
    "/api/public/store/v1/featured": {
      "get": {
        "tags": [
          "App Store"
        ],
        "summary": "Get the featured shelf",
        "description": "App of the week and the featured apps.",
        "x-source": "app_store.controller.ts",
        "operationId": "getPublicStoreFeatured",
        "security": [],
        "responses": {
          "200": {
            "description": "Featured apps.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "appOfTheWeek": {
                              "type": [
                                "object",
                                "null"
                              ]
                            },
                            "featured": {
                              "type": "array"
                            }
                          }
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 200,
                  "data": {
                    "appOfTheWeek": {
                      "id": 1,
                      "slug": "salon-booking",
                      "name": "Salon Booking",
                      "kind": "app",
                      "tagline": "Stylists, time slots, deposits and text reminders.",
                      "category": {
                        "slug": "bookings",
                        "name": "Bookings",
                        "tone": "bookings"
                      },
                      "developer": {
                        "name": "EzyStudio",
                        "slug": "ezystudio"
                      },
                      "icon": {
                        "url": "https://ezystudio.com/api/public/app-media/v1/apps/1/demo/icon.svg",
                        "alt": "Salon Booking icon"
                      },
                      "pricing": {
                        "model": "included",
                        "priceCents": null,
                        "currency": "USD"
                      },
                      "rating": {
                        "average": 4.4,
                        "count": 7
                      },
                      "installsCount": 44451,
                      "isFeatured": true,
                      "isFirstParty": true
                    },
                    "featured": [
                      {
                        "id": 1,
                        "slug": "salon-booking",
                        "name": "Salon Booking",
                        "kind": "app",
                        "tagline": "Stylists, time slots, deposits and text reminders.",
                        "category": {
                          "slug": "bookings",
                          "name": "Bookings",
                          "tone": "bookings"
                        },
                        "developer": {
                          "name": "EzyStudio",
                          "slug": "ezystudio"
                        },
                        "icon": {
                          "url": "https://ezystudio.com/api/public/app-media/v1/apps/1/demo/icon.svg",
                          "alt": "Salon Booking icon"
                        },
                        "pricing": {
                          "model": "included",
                          "priceCents": null,
                          "currency": "USD"
                        },
                        "rating": {
                          "average": 4.4,
                          "count": 7
                        },
                        "installsCount": 44451,
                        "isFeatured": true,
                        "isFirstParty": true
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/store/v1/featured"
      }
    },
    "/api/public/banner/v1": {
      "get": {
        "tags": [
          "Plans"
        ],
        "summary": "Announcement bar for a page",
        "description": "The sale banner to show on a marketing page (or in the customer app): headline, countdown end, the public promo code while it is live, and where the button goes. `banner` is null when nothing shows. Cached 60 s.",
        "x-source": "public_banner.controller.ts",
        "operationId": "getPublicBannerV1",
        "security": [],
        "parameters": [
          {
            "name": "path",
            "in": "query",
            "required": false,
            "description": "The page path, e.g. `/pricing` (default `/`).",
            "schema": {
              "type": "string",
              "maxLength": 300
            }
          },
          {
            "name": "surface",
            "in": "query",
            "required": false,
            "description": "`site` (default): marketing pages. `app`: the customer app.",
            "schema": {
              "enum": [
                "site",
                "app"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The banner, or null.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "banner": {
                              "type": "object",
                              "nullable": true
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        },
        "x-adonis-route": "GET /api/public/banner/v1"
      }
    },
    "/api/public/plans": {
      "get": {
        "tags": [
          "Plans"
        ],
        "summary": "List plans",
        "description": "The plan catalogue with prices and resources (plans + add-ons). On an agency's dashboard address: that agency's own plans, and browsers may read it from the agency's own website domains (CORS, never with credentials).",
        "x-source": "public_plans.controller.ts",
        "operationId": "getPublicPlans",
        "security": [],
        "parameters": [
          {
            "name": "audience",
            "in": "query",
            "required": false,
            "description": "`account` (default): customer plans. `agency`: partner plans.",
            "schema": {
              "enum": [
                "account",
                "agency"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Plans.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "audience": {
                              "type": "string"
                            },
                            "checkoutOpen": {
                              "type": "boolean"
                            },
                            "opensLaterReason": {
                              "type": "string",
                              "nullable": true
                            },
                            "plans": {
                              "type": "array"
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        },
        "x-adonis-route": "GET /api/public/plans"
      }
    },
    "/api/public/pricing/v1": {
      "get": {
        "tags": [
          "Plans"
        ],
        "summary": "Plan cards and comparison table",
        "description": "Each plan's card content (description, badge, call to action, highlights rendered from its live limits and prices) and the comparison table, per audience and locale (English fallback). ETag + 60 s cache.",
        "x-source": "public_pricing.controller.ts",
        "operationId": "getPublicPricingV1",
        "security": [],
        "parameters": [
          {
            "name": "audience",
            "in": "query",
            "required": false,
            "description": "`account` (default): customer plans. `agency`: partner plans.",
            "schema": {
              "enum": [
                "account",
                "agency"
              ]
            }
          },
          {
            "name": "locale",
            "in": "query",
            "required": false,
            "description": "Text locale, e.g. `en` (default) or `bn`; missing text falls back to English.",
            "schema": {
              "type": "string",
              "pattern": "^[a-z]{2}(-[a-z]{2})?$"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cards and table.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "audience": {
                              "type": "string"
                            },
                            "locale": {
                              "type": "string"
                            },
                            "plans": {
                              "type": "array"
                            },
                            "comparison": {
                              "type": "object"
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        },
        "x-adonis-route": "GET /api/public/pricing/v1"
      }
    },
    "/api/public/experts/{slug}": {
      "get": {
        "tags": [
          "Partners"
        ],
        "summary": "Get a freelance expert",
        "description": "One approved, listed expert (card + bio).",
        "x-source": "partner.controller.ts `expert`",
        "operationId": "getPublicExpertsSlug",
        "security": [],
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "Expert slug.",
            "schema": {
              "type": "string",
              "maxLength": 64
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The expert.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "Not a listed expert.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/experts/:slug"
      }
    },
    "/api/public/join/{token}": {
      "get": {
        "tags": [
          "Partners"
        ],
        "summary": "Read a sale link offer",
        "description": "A SaaS-mode agency's sale link (or one client's pay link), on the agency's own host: `{status (open|expired|used_up|disabled), agency {name, brand}, plan {id, name, description, resources}, price {id, amount, currency, interval, trialDays}, snapshot, forAccount, account, signedIn, cardRequired}`. Joining and paying need a session (`POST /api/join/:token/start`, `/checkout`).",
        "x-source": "saas/join.controller.ts `show`",
        "operationId": "getPublicJoinToken",
        "security": [],
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "description": "The link token (`/join/<token>`).",
            "schema": {
              "type": "string",
              "minLength": 8,
              "maxLength": 32
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The offer.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "402": {
            "description": "The agency no longer has SaaS mode.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "status": 402,
                  "errors": [
                    {
                      "message": "This feature is not included in your plan.",
                      "code": "E_RESOURCE_LIMIT_EXCEEDED"
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "No such link on this host.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                },
                "example": {
                  "status": 404,
                  "errors": [
                    {
                      "message": "This link doesn’t exist.",
                      "code": "E_NOT_FOUND"
                    }
                  ]
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/join/:token"
      }
    },
    "/api/public/leads": {
      "post": {
        "tags": [
          "Partners"
        ],
        "summary": "Ask for an expert",
        "description": "“Hire an expert”: matches up to 3 partners. Anonymous or signed in.",
        "x-source": "partner.controller.ts `createLead`",
        "operationId": "postPublicLeads",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "need": {
                    "enum": [
                      "finish_app",
                      "from_scratch",
                      "design_refresh",
                      "ongoing_care"
                    ]
                  },
                  "budgetBand": {
                    "enum": [
                      "lt_500",
                      "500_1500",
                      "1500_5000",
                      "gt_5000",
                      "unsure"
                    ]
                  },
                  "timeline": {
                    "enum": [
                      "asap",
                      "two_weeks",
                      "month",
                      "no_rush"
                    ]
                  },
                  "language": {
                    "type": "string",
                    "pattern": "^[a-z]{2}(-[A-Z]{2})?$"
                  },
                  "country": {
                    "type": "string",
                    "pattern": "^[A-Z]{2}$"
                  },
                  "details": {
                    "type": "string",
                    "maxLength": 4000
                  },
                  "siteId": {
                    "type": "integer",
                    "minimum": 0
                  },
                  "contactName": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 120
                  },
                  "contactEmail": {
                    "type": "string",
                    "format": "email",
                    "maxLength": 254
                  },
                  "partner": {
                    "type": "object",
                    "properties": {
                      "type": {
                        "enum": [
                          "agency",
                          "expert"
                        ]
                      },
                      "slug": {
                        "type": "string",
                        "maxLength": 64
                      }
                    },
                    "required": [
                      "type",
                      "slug"
                    ]
                  }
                },
                "required": [
                  "need",
                  "budgetBand",
                  "timeline",
                  "language"
                ]
              },
              "example": {
                "need": "finish_app",
                "budgetBand": "500_1500",
                "timeline": "month",
                "language": "en"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The lead and its matches (with the requester’s token).",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "x-rate-limit": "10 an hour per IP.",
        "x-adonis-route": "POST /api/public/leads"
      }
    },
    "/api/public/leads/{publicId}": {
      "get": {
        "tags": [
          "Partners"
        ],
        "summary": "Get your lead",
        "description": "The requester’s lead and its matches.",
        "x-source": "partner.controller.ts `showLead`",
        "operationId": "getPublicLeadsPublicId",
        "security": [],
        "parameters": [
          {
            "name": "publicId",
            "in": "path",
            "required": true,
            "description": "The lead’s public id.",
            "schema": {
              "type": "string",
              "minLength": 16,
              "maxLength": 16
            }
          },
          {
            "name": "token",
            "in": "query",
            "required": false,
            "description": "The requester’s token, when not signed in.",
            "schema": {
              "type": "string",
              "maxLength": 128
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The lead.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "No such lead for this requester.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/leads/:publicId"
      }
    },
    "/api/public/leads/{publicId}/contact": {
      "post": {
        "tags": [
          "Partners"
        ],
        "summary": "Contact a matched partner",
        "description": "Offers the lead to one of its matched partners.",
        "x-source": "partner.controller.ts `contact`",
        "operationId": "postPublicLeadsPublicIdContact",
        "security": [],
        "parameters": [
          {
            "name": "publicId",
            "in": "path",
            "required": true,
            "description": "The lead’s public id.",
            "schema": {
              "type": "string",
              "minLength": 16,
              "maxLength": 16
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "token": {
                    "type": "string",
                    "maxLength": 128
                  },
                  "partner": {
                    "type": "string",
                    "maxLength": 64
                  },
                  "type": {
                    "enum": [
                      "agency",
                      "expert"
                    ]
                  }
                },
                "required": [
                  "partner"
                ]
              },
              "example": {
                "partner": "casa-pixel"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Offered.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "No such lead or partner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "x-rate-limit": "30 an hour per IP.",
        "x-adonis-route": "POST /api/public/leads/:publicId/contact"
      }
    },
    "/api/public/partner-levels": {
      "get": {
        "tags": [
          "Partners"
        ],
        "summary": "List partner levels",
        "description": "Ladder tiers (Starter → Rising → Pro → Elite, Founding): points floor, referral %, perks, and founding spots left.",
        "x-source": "partner.controller.ts `levels`",
        "operationId": "getPublicPartnerLevels",
        "security": [],
        "responses": {
          "200": {
            "description": "Levels.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object",
                          "properties": {
                            "levels": {
                              "type": "array"
                            },
                            "foundingSpots": {
                              "type": "object"
                            }
                          }
                        }
                      }
                    }
                  ]
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/partner-levels"
      }
    },
    "/api/public/partners": {
      "get": {
        "tags": [
          "Partners"
        ],
        "summary": "List listed partners",
        "description": "The expert directory.",
        "x-source": "partner.controller.ts `directory`",
        "operationId": "getPublicPartners",
        "security": [],
        "parameters": [
          {
            "name": "country",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^[A-Z]{2}$"
            }
          },
          {
            "name": "language",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "pattern": "^[a-z]{2}(-[A-Z]{2})?$"
            }
          },
          {
            "name": "type",
            "in": "query",
            "required": false,
            "schema": {
              "enum": [
                "agency",
                "expert"
              ]
            }
          },
          {
            "name": "skill",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 40
            }
          },
          {
            "name": "level",
            "in": "query",
            "required": false,
            "schema": {
              "enum": [
                "starter",
                "rising",
                "pro",
                "elite",
                "founding"
              ]
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "maxLength": 512
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of partners.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        },
        "x-adonis-route": "GET /api/public/partners"
      }
    },
    "/api/public/partners/{slug}": {
      "get": {
        "tags": [
          "Partners"
        ],
        "summary": "Get a partner",
        "operationId": "getPublicPartnersSlug",
        "security": [],
        "parameters": [
          {
            "name": "slug",
            "in": "path",
            "required": true,
            "description": "Partner slug.",
            "schema": {
              "type": "string",
              "maxLength": 64
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The partner.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "Not a listed partner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/partners/:slug"
      }
    },
    "/api/public/referral-program/v1/click": {
      "post": {
        "tags": [
          "Partners"
        ],
        "summary": "Record a click on an agency client's referral link",
        "description": "An agency's own client referral programme, on the agency's host (404 on the platform's host): a `?ref=CODE` landing (landing path + salted IP hash, no other PII). `{recorded}` is false for an unknown, paused or inactive code, or while the agency's programme is off.",
        "x-source": "agency_referrals.controller.ts `click`",
        "operationId": "postPublicReferralProgramClick",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              },
              "example": {
                "code": "MAYA-7K2Q",
                "path": "/pricing"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Whether the click counted.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "Not on an agency's host.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "x-rate-limit": "120 an hour per IP and agency; floods from one IP stop counting.",
        "x-adonis-route": "POST /api/public/referral-program/v1/click"
      }
    },
    "/api/public/referral-program/v1/codes/{code}": {
      "get": {
        "tags": [
          "Partners"
        ],
        "summary": "Check an agency client's referral code",
        "description": "An agency's own client referral programme, on the agency's host: `{valid, reason (not_found|inactive|self), discountPercent, referrer}`. 404 on the platform's host or while the agency has no live programme.",
        "x-source": "agency_referrals.controller.ts `check`",
        "operationId": "getPublicReferralProgramCodesCode",
        "security": [],
        "parameters": [
          {
            "name": "code",
            "in": "path",
            "required": true,
            "description": "Referral code (case-insensitive).",
            "schema": {
              "type": "string",
              "minLength": 2,
              "maxLength": 32
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The check.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "404": {
            "description": "Not on an agency's host, or the agency has no live programme.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "x-rate-limit": "60 an hour per user or IP and agency.",
        "x-adonis-route": "GET /api/public/referral-program/v1/codes/:code"
      }
    },
    "/api/public/referrals/v1/click": {
      "post": {
        "tags": [
          "Partners"
        ],
        "summary": "Record a referral link click",
        "description": "A `?ref=CODE` landing (landing path + salted IP hash, no other PII).",
        "x-source": "referrals.controller.ts `click`",
        "operationId": "postPublicReferralsClick",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object"
              },
              "example": {
                "code": "MAYA-7K2Q",
                "path": "/pricing"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Whether the click counted.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "x-rate-limit": "120 an hour per IP; floods from one IP stop counting.",
        "x-adonis-route": "POST /api/public/referrals/v1/click"
      }
    },
    "/api/public/referrals/v1/codes/{code}": {
      "get": {
        "tags": [
          "Partners"
        ],
        "summary": "Check a referral code",
        "description": "`{valid, reason (not_found|inactive|self), referrer, perk}`.",
        "x-source": "referrals.controller.ts `check`",
        "operationId": "getPublicReferralsCodesCode",
        "security": [],
        "parameters": [
          {
            "name": "code",
            "in": "path",
            "required": true,
            "description": "Referral code (case-insensitive).",
            "schema": {
              "type": "string",
              "maxLength": 32
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The check.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "x-rate-limit": "60 an hour per user or IP.",
        "x-adonis-route": "GET /api/public/referrals/v1/codes/:code"
      }
    },
    "/api/public/referrals/v1/program": {
      "get": {
        "tags": [
          "Partners"
        ],
        "summary": "Referral programme terms",
        "description": "Tiers, commission months, the welcome perk and the points rules.",
        "x-source": "referrals.controller.ts `program`",
        "operationId": "getPublicReferralsProgram",
        "security": [],
        "responses": {
          "200": {
            "description": "The programme.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/referrals/v1/program"
      }
    },
    "/api/public/app-media/v1/{path}": {
      "get": {
        "tags": [
          "Platform"
        ],
        "summary": "Get an app media file",
        "description": "Listing images while EzyStudio stores media itself (with object storage, media URLs point at the bucket). Bundles are never served.",
        "x-source": "apps.controller.ts `serve`",
        "operationId": "getPublicAppMedia",
        "security": [],
        "parameters": [
          {
            "name": "path",
            "in": "path",
            "required": true,
            "description": "The storage key, e.g. `apps/42/screenshot/01jb2x5m3a.png`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The file (PNG, JPEG, WebP or SVG).",
            "content": {
              "image/*": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "No such file.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/app-media/v1/*"
      }
    },
    "/api/public/brand": {
      "get": {
        "tags": [
          "Platform"
        ],
        "summary": "Get the host’s brand",
        "description": "The agency whose dashboard host this is: EzyStudio itself, or a white-label partner’s brand.",
        "x-source": "agency.controller.ts `publicBrand`",
        "operationId": "getPublicBrand",
        "security": [],
        "responses": {
          "200": {
            "description": "The brand.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/Envelope"
                    },
                    {
                      "type": "object",
                      "properties": {
                        "data": {
                          "type": "object"
                        }
                      }
                    }
                  ]
                },
                "example": {
                  "status": 200,
                  "data": {
                    "isMaster": true,
                    "name": "EzyStudio",
                    "brand": null,
                    "clientSitePattern": null
                  }
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/brand"
      }
    },
    "/api/public/email-assets/{file}": {
      "get": {
        "tags": [
          "Platform"
        ],
        "summary": "Get an email image",
        "description": "Images used in EzyStudio emails (`brand-mark.png`).",
        "x-source": "health.controller.ts `emailAsset`",
        "operationId": "getPublicEmailAssetsFile",
        "security": [],
        "parameters": [
          {
            "name": "file",
            "in": "path",
            "required": true,
            "description": "File name, e.g. `brand-mark.png`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The image.",
            "content": {
              "image/png": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Unknown file."
          }
        },
        "x-adonis-route": "GET /api/public/email-assets/:file"
      }
    },
    "/api/public/privacy/v1/exports/{token}": {
      "get": {
        "tags": [
          "Platform"
        ],
        "summary": "Download your data export",
        "description": "The link in the “your data export is ready” email: the ZIP of a person’s own data. No session; the token is the authorization, and it only works on the host of the agency that sent it.",
        "x-source": "privacy.controller.ts `download`",
        "operationId": "getPublicPrivacyExportsToken",
        "security": [],
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "description": "The token from the email link.",
            "schema": {
              "type": "string",
              "maxLength": 80
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The export.",
            "content": {
              "application/zip": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "The link is not valid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "410": {
            "description": "`E_PRIVACY_LINK_EXPIRED`: the link has expired.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "x-rate-limit": "30 an hour per IP.",
        "x-adonis-route": "GET /api/public/privacy/v1/exports/:token"
      }
    },
    "/api/public/review-source/v1/{token}": {
      "get": {
        "tags": [
          "Platform"
        ],
        "summary": "Download a version’s frozen source (staff link)",
        "description": "The “Download source” link of the staff review screen: a signed token, valid 5 minutes, minted for staff holding `platform:review:source` (`POST /api/staff/v1/review/:id/source`) or an admin holding `app_versions.source` (`POST /api/admin/v1/developers/submissions/:id/source`). Answers the exact bytes of the frozen bundle (sha256-checked), as an attachment, never cached. Expired, tampered or superseded links are 404.",
        "x-source": "ai_review.controller.ts `source`",
        "operationId": "getPublicReviewSourceToken",
        "security": [],
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "description": "The signed link token.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The bundle ZIP.",
            "content": {
              "application/zip": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "Expired, tampered, or no such source.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/review-source/v1/:token"
      }
    },
    "/api/public/site-database/v1/backups/{ticket}": {
      "get": {
        "tags": [
          "Platform"
        ],
        "summary": "Download a site database backup",
        "description": "The 5-minute link from `POST …/sites/:siteId/database/v1/backups/download` (owner or admin, `site_database:update`): the backup as a `pg_dump` custom-format file (`pg_restore` reads it). No session; the sealed ticket is the authorization. It works once, only on the host of the agency that issued it, and only while the backup is ready and not expired. Sent with `Cache-Control: no-store` and `Content-Disposition: attachment`.",
        "x-source": "site_database_backup.controller.ts `fetch`",
        "operationId": "getPublicSiteDatabaseBackupsTicket",
        "security": [],
        "parameters": [
          {
            "name": "ticket",
            "in": "path",
            "required": true,
            "description": "The sealed ticket from the download link.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The backup file.",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "404": {
            "description": "`E_SITE_DATABASE_BACKUP_LINK`: the link is not valid, was already used, has expired, or the backup is gone.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/TooManyRequests"
          }
        },
        "x-rate-limit": "30 every 10 minutes per IP.",
        "x-adonis-route": "GET /api/public/site-database/v1/backups/:ticket"
      }
    },
    "/api/public/site-files/v1/files/{token}": {
      "get": {
        "tags": [
          "Platform"
        ],
        "summary": "Signed file link (local development)",
        "description": "Where a signed link points when the platform stores files on its own disk (local development). Use the `url` from “Get a signed link to a file”.",
        "x-source": "site_files.public.ts `download`",
        "operationId": "getPublicSiteFilesFilesToken",
        "security": [],
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "required": true,
            "description": "Sealed, expiring link token.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The file, with its type and a download or inline disposition.",
            "content": {
              "application/octet-stream": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          },
          "403": {
            "description": "`E_DOWNLOAD_TOKEN`: expired or not valid.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "`E_FILE_NOT_FOUND`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/site-files/v1/files/:token"
      }
    },
    "/api/public/v1/locale": {
      "get": {
        "tags": [
          "Platform"
        ],
        "summary": "Default language for this visitor",
        "description": "The language a first-time visitor sees, from their country (Cloudflare `CF-IPCountry`): `{locale, country}`. English when the country has no translated language or is unknown. Not cached in shared caches (`Cache-Control: private, no-store`); the browser keeps the answer in a cookie for 30 days.",
        "x-source": "locale/public_locale.controller.ts",
        "operationId": "getPublicLocale",
        "security": [],
        "responses": {
          "200": {
            "description": "The language and the country it came from (null when unknown).",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "locale": {
                      "type": "string",
                      "example": "de"
                    },
                    "country": {
                      "type": "string",
                      "nullable": true,
                      "example": "DE"
                    }
                  },
                  "required": [
                    "locale",
                    "country"
                  ]
                },
                "example": {
                  "locale": "de",
                  "country": "DE"
                }
              }
            }
          }
        },
        "x-adonis-route": "GET /api/public/v1/locale"
      }
    }
  },
  "components": {
    "securitySchemes": {
      "siteKey": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "stk_<publicId 16>_<secret 43>",
        "description": "The site’s Site Key, from the `SITE_KEY` secret EzyStudio sets on the deployed site. Server side only."
      }
    },
    "schemas": {
      "Envelope": {
        "description": "Every JSON success answer: `{status, data}`, plus `message` on writes (start/macros/response.ts `sendFormatted`).",
        "type": "object",
        "properties": {
          "status": {
            "type": "integer",
            "description": "The HTTP status, repeated."
          },
          "data": {
            "description": "The payload (see each operation)."
          },
          "message": {
            "type": "string",
            "description": "Words for people, in the caller’s language. Never branch on it."
          }
        },
        "required": [
          "status"
        ]
      },
      "Error": {
        "description": "Every error answer: `{status, errors: [{message, code}]}` (`sendError`). Branch on `code`, never on `message`.",
        "type": "object",
        "properties": {
          "status": {
            "type": "integer"
          },
          "errors": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "message": {
                  "type": "string"
                },
                "code": {
                  "type": "string",
                  "description": "Machine code, e.g. `E_INVALID_SITE_KEY`."
                },
                "meta": {
                  "type": "object",
                  "description": "Structured detail for some codes."
                }
              },
              "required": [
                "message"
              ]
            }
          }
        },
        "required": [
          "errors"
        ]
      },
      "ValidationError": {
        "description": "422 from a Vine validator: one entry per failed rule (no `status` key in this shape).",
        "type": "object",
        "properties": {
          "errors": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "message": {
                  "type": "string"
                },
                "rule": {
                  "type": "string",
                  "description": "The failed rule, e.g. `maxLength`, `required`, `email`."
                },
                "field": {
                  "type": "string",
                  "description": "The field path, e.g. `media.0.altText`."
                },
                "meta": {
                  "type": "object"
                }
              },
              "required": [
                "message",
                "rule",
                "field"
              ]
            }
          }
        },
        "required": [
          "errors"
        ]
      },
      "CursorMeta": {
        "type": "object",
        "description": "Keyset pages: pass `nextCursor` back as `cursor`. There are no totals.",
        "properties": {
          "nextCursor": {
            "type": [
              "string",
              "null"
            ]
          },
          "hasNextPage": {
            "type": "boolean"
          }
        },
        "required": [
          "nextCursor",
          "hasNextPage"
        ]
      },
      "PublicSite": {
        "description": "The public projection of a site (site_public.controller.ts `publicSiteProjection`). Never the account, the owner or a setting.",
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          },
          "kind": {
            "enum": [
              "static",
              "app"
            ],
            "description": "`static` = website, `app` = logins, payments, database."
          },
          "template": {
            "type": [
              "string",
              "null"
            ],
            "description": "`astro-app` or `astro-static`."
          },
          "url": {
            "type": [
              "string",
              "null"
            ],
            "description": "The site’s primary address (custom domain, else `<slug>.ezystudio.app`)."
          },
          "logo": {
            "type": [
              "string",
              "null"
            ]
          },
          "favIcon": {
            "type": [
              "string",
              "null"
            ]
          },
          "brandColor": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "id",
          "name",
          "slug",
          "kind",
          "template",
          "url",
          "logo",
          "favIcon",
          "brandColor"
        ]
      },
      "AppManifest": {
        "title": "App manifest",
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "minLength": 1,
            "maxLength": 80,
            "description": "The app’s name on the store."
          },
          "categorySlug": {
            "type": "string",
            "maxLength": 48,
            "description": "One of `GET /api/public/store/v1/categories` (bookings, food, selling, learning, events, listings, business, websites)."
          },
          "tagline": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 60,
            "description": "One line under the name. Required to submit."
          },
          "description": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 5000,
            "description": "What the app does, in plain words. At least 40 characters to submit."
          },
          "dataNote": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2000,
            "description": "What the app stores about the owner’s customers and why. Shown under “Your data”. At least 20 characters to submit."
          },
          "supportEmail": {
            "type": [
              "string",
              "null"
            ],
            "format": "email",
            "maxLength": 254,
            "description": "Where buyers reach you. Falls back to your developer profile’s support email."
          },
          "languages": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string",
              "maxLength": 10
            },
            "maxItems": 30,
            "description": "Language codes the app works in, e.g. `en`, `es`."
          },
          "plans": {
            "type": "array",
            "items": {
              "enum": [
                "website",
                "app",
                "business"
              ]
            },
            "maxItems": 3,
            "description": "EzyStudio plans the app works on. At least one to submit."
          },
          "pricingModel": {
            "enum": [
              "free",
              "one_time",
              "monthly",
              "included"
            ],
            "description": "`free`, `one_time` or `monthly`. `included` is for EzyStudio’s own apps only (422 otherwise)."
          },
          "priceCents": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 99900,
            "description": "Price in cents for `one_time` and `monthly`: from the minimum paid app price (`priceMinimum` on the app, set by EzyStudio; $3 by default) to $999 (99900). A lower price is refused with `E_PRICE_BELOW_MINIMUM` (`meta.minimumCents`). An app already live below the minimum may keep or raise its price, never lower it. Cleared for free."
          },
          "currency": {
            "enum": [
              "USD"
            ],
            "description": "USD only for now."
          },
          "customization": {
            "type": "object",
            "properties": {
              "colors": {
                "type": "boolean",
                "description": "Colors and fonts."
              },
              "text": {
                "type": "boolean",
                "description": "Names, labels and copy."
              },
              "fields": {
                "type": "boolean",
                "description": "Form fields."
              },
              "layout": {
                "type": "boolean",
                "description": "Layout and order of sections."
              },
              "sample": {
                "type": "string",
                "maxLength": 500,
                "description": "An example sentence for buyers."
              }
            },
            "description": "What a buyer’s one-sentence customization may change. Defaults: colors, text and fields on, layout off."
          },
          "included": {
            "type": "array",
            "items": {},
            "maxItems": 50,
            "description": "Listing bullets: what the app includes (strings or objects, up to 50)."
          },
          "features": {
            "type": "array",
            "items": {},
            "maxItems": 50,
            "description": "Feature cards on the listing, e.g. `{icon, group, title, description}` (up to 50)."
          },
          "setupSteps": {
            "type": "array",
            "items": {},
            "maxItems": 50,
            "description": "Steps shown while the app installs. Strings or `{title}` / `{label}` objects; the first 6 are used."
          },
          "madeFor": {
            "type": "array",
            "items": {},
            "maxItems": 50,
            "description": "Kinds of businesses it is for (up to 50)."
          },
          "trySaying": {
            "type": "array",
            "items": {},
            "maxItems": 50,
            "description": "Example customization sentences (up to 50)."
          },
          "changelog": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2000,
            "description": "What’s new: release notes customers see on the listing’s changelog. Up to 2,000 characters. Required for every version after the first, optional on the first."
          },
          "customerImpact": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 2000,
            "description": "What customers might lose: removed features, changed or deleted data, breaking changes. Up to 2,000 characters. Required after the first version unless `customerImpactNone` is true."
          },
          "customerImpactNone": {
            "type": "boolean",
            "description": "True when nothing is removed or changed in a way customers lose (instead of `customerImpact`)."
          },
          "aiUpdateNotes": {
            "type": [
              "string",
              "null"
            ],
            "maxLength": 8000,
            "description": "Update instructions for the AI that applies the update to each customer’s customised copy: what changed since the previous version, data migrations, moved files, how to carry customisations over. Up to 8,000 characters. Required after the first version."
          },
          "version": {
            "type": "string",
            "pattern": "^\\d{1,4}\\.\\d{1,4}(\\.\\d{1,4})?$",
            "description": "The version number you propose: `major.minor` or `major.minor.patch`. Unique per app, and higher than the live version. A new draft starts at the next minor (`1.0` → `1.1`). EzyStudio confirms the final number when it publishes the version."
          },
          "repo": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "installationId": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "GitHub’s installation id, from the GitHub connection’s installations."
              },
              "repositoryId": {
                "type": "integer",
                "minimum": 0,
                "maximum": 9007199254740991,
                "description": "GitHub’s repository id (one the installation covers)."
              },
              "branch": {
                "type": "string",
                "minLength": 1,
                "maxLength": 255,
                "description": "The branch to take the code from."
              },
              "recheck": {
                "type": "boolean",
                "description": "Check the branch’s latest commit again even when the pick is unchanged."
              }
            },
            "required": [
              "installationId",
              "repositoryId",
              "branch"
            ],
            "description": "Where the app’s code comes from: a GitHub repository and branch of your developer team’s GitHub connection (connect it on the submit form). Saving it checks the branch’s latest commit against the bundle rules (`422 E_BUNDLE_INVALID`, `meta.field: repo`). At submit we freeze the branch’s latest commit: its files are what reviewers test and customers install. Required to submit. `null` clears it."
          },
          "media": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "kind": {
                  "enum": [
                    "icon",
                    "cover",
                    "screenshot"
                  ],
                  "description": "icon, cover or screenshot."
                },
                "key": {
                  "type": "string",
                  "maxLength": 512,
                  "description": "The `key` from the presign answer (preferred)."
                },
                "url": {
                  "type": "string",
                  "format": "uri",
                  "maxLength": 1024,
                  "description": "Only a URL this API returned for one of this app’s files. New outside URLs are refused."
                },
                "altText": {
                  "type": "string",
                  "maxLength": 250,
                  "description": "Required on every image to submit."
                },
                "title": {
                  "type": "string",
                  "maxLength": 120,
                  "description": "Optional caption."
                },
                "width": {
                  "type": "integer",
                  "minimum": 1,
                  "maximum": 20000,
                  "description": "Pixels."
                },
                "height": {
                  "type": "integer",
                  "minimum": 1,
                  "maximum": 20000,
                  "description": "Pixels."
                }
              },
              "required": [
                "kind"
              ]
            },
            "maxItems": 10,
            "description": "Replaces the whole list. Up to 1 icon, 1 cover and 8 screenshots; order = position within each kind."
          }
        }
      }
    },
    "responses": {
      "ValidationFailed": {
        "description": "The body or query failed validation.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/ValidationError"
            }
          }
        }
      },
      "TooManyRequests": {
        "description": "Rate limited. The body has no `code`: `{\"errors\": [{\"message\": \"Too many requests\", \"retryAfter\": 60}]}`. Wait the `Retry-After` seconds, then retry.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    }
  }
}
