API reference
Every public and Site API endpoint, with its parameters, limits and answers. Generated from the API's own routes and validators.
Download the OpenAPI 3.1 document: openapi.json. Base URL https://ezystudio.com; Site API and public site calls go to the site's own address.
Conventions
- JSON answers are
{status, data}, plusmessageon writes. Errors are{status, errors: [{message, code}]}: branch oncode, never onmessage(it is translated). - Validation errors (422) list every failed rule:
{errors: [{message, rule, field}]}. - Lists are keyset pages: pass
meta.nextCursorback ascursor. There are no totals. - Over a limit you get 429 with a
Retry-Afterheader.
| Credential | Header | Format |
|---|---|---|
| Site Key | Authorization: Bearer … | stk_<publicId 16>_<secret 43> The site’s Site Key, from the SITE_KEY secret EzyStudio sets on the deployed site. Server side only. |
Site API
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.
Check that an address is a real inbox
/api/site/v1/email-checkBefore 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.
Request body
| Field | Type and limits | What it is |
|---|---|---|
emailRequired | stringemail |
{
"email": "maya@lumahair.test"
}Responses
object{
"status": 200,
"data": {
"accept": true
}
}Relay an account email
/api/site/v1/emailsAsk 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.
Request body
| Field | Type and limits | What it is |
|---|---|---|
kindRequired | "password_reset" | "verify_email" | "password_changed" | "account_exists" | "notification" | "confirm_email_change" | "email_changed" | "sign_in_code" | notification is plain text to one of the site’s owners only. |
to | stringup to 254 characters, email | The recipient. |
path | stringup to 512 characters, pattern ^\/(?![/\\])[^\s\\\u0000-\u001f\u007f]*$ | Site-relative link (/account/reset?token=…): one leading /, never // or \. Required for password_reset, verify_email and confirm_email_change. |
expiresInMinutes | integer1 to 10080 | Shown in the email (“this link expires in …”). Up to one week. |
subject | string1 to 120 characters | Required for notification. |
text | string1 to 2000 characters | Plain text, escaped when rendered. Required for notification. |
code | stringpattern ^\d{6}$ | sign_in_code only: exactly 6 digits, shown in the email. |
{
"kind": "password_reset",
"to": "maya@lumahair.test",
"path": "/account/reset?token=8f2c…",
"expiresInMinutes": 30
}Responses
object{
"status": 202,
"message": "Email accepted for delivery.",
"data": {
"accepted": true
}
}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.Body: Error{
"status": 401,
"errors": [
{
"message": "Invalid Site Key.",
"code": "E_INVALID_SITE_KEY"
}
]
}E_SITE_HAS_NO_HOST: the site has no address to build the link on.Body: ErrorE_SITE_EMAIL_RECIPIENT (a notification to someone other than the site owner).Body: ValidationError | Errorcode: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: ErrorList files
/api/site/v1/filesReady 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.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
before | query | stringup to 40 characters | The next value of the previous page. |
limit | query | integer1 to 100 | 1 to 100. Default 50. |
Responses
{files, next}; next is null on the last page.Body: objectErrorGet one file
/api/site/v1/files/{fileId}The file’s details (never its bytes: ask for a link).
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
fileIdRequired | path | stringpattern ^file_[0-9a-z]{26}$ | The file id (file_…). |
Complete a file upload
/api/site/v1/files/{fileId}/completeStep 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).
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
fileIdRequired | path | stringpattern ^file_[0-9a-z]{26}$ | The file id (file_…). |
Responses
ready.Body: objectErrorE_STORAGE_UNAVAILABLE.Body: ErrorE_FILE_NOT_FOUND: no such file on this site (and key kind).Body: ErrorE_FILE_NOT_UPLOADED: nothing stored yet. PUT the file, then call again.Body: ErrorE_UPLOAD_EXPIRED: the grant expired (1 hour). Ask for a new upload.Body: ErrorE_FILE_SIZE_MISMATCH: the stored file was not the declared size and was deleted.Body: ErrorDelete a file
/api/site/v1/files/{fileId}/deleteDeletes 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).
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
fileIdRequired | path | stringpattern ^file_[0-9a-z]{26}$ | The file id (file_…). |
Get a signed link to a file
/api/site/v1/files/{fileId}/urlA 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.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
fileIdRequired | path | stringpattern ^file_[0-9a-z]{26}$ | The file id (file_…). |
Request body
| Field | Type and limits | What it is |
|---|---|---|
expiresIn | integer1 to 604800 | Seconds the link works: 60 to 604800 (7 days). Default 3600. |
download | boolean | true = the browser saves the file instead of showing it. |
{
"expiresIn": 600
}Responses
object{
"status": 200,
"data": {
"url": "https://files.example/s42/live/01jbz3k8….pdf?token=…&expires=1793102400",
"expiresAt": "2026-10-27T10:10:00.000Z"
}
}ErrorE_STORAGE_UNAVAILABLE.Body: ErrorE_FILE_NOT_FOUND.Body: ErrorE_FILE_NOT_READY: call complete first.Body: ErrorStart a file upload
/api/site/v1/files/uploadsStep 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).
Request body
| Field | Type and limits | What it is |
|---|---|---|
nameRequired | string1 to 400 characters | Display name, e.g. cv.pdf. Paths and control characters are removed; up to 200 characters are kept. |
contentTypeRequired | string3 to 127 characters | The file’s MIME type, e.g. image/jpeg. Must be on the allow-list. |
sizeRequired | integer0 or more | Exact size in bytes. The stored file must match it exactly. |
visibility | "private" | "public" | private (default): only signed links. public: also a stable link on the site’s own address, /__platform/files/<id>/<name>. |
{
"name": "cv.pdf",
"contentType": "application/pdf",
"size": 482113
}Responses
upload.url (on the site’s own address) with upload.headers, then call complete.Body: object{
"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"
}
}
}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.Body: Error{
"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"
}
]
}E_STORAGE_OFF (turned off for this site) or E_STORAGE_UNAVAILABLE (no storage for this account).Body: ErrorErrorStorage used and the limit
/api/site/v1/files/usageThe 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.
Send background jobs
/api/site/v1/jobsSend 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).
Request body
{
"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
{jobs: [...]}.Body: object{
"status": 202,
"message": "Job accepted.",
"data": {
"id": "job_abcdefghijklmnopqrstuvwxyz",
"list": "booking-reminders",
"runAt": "2026-11-01T09:00:00.000Z",
"deduplicated": false
}
}E_JOBS_EDITOR_KEY: jobs are not sent from the editor.Body: ErrorE_JOBS_NOT_AN_APP: a website (static) has no background jobs.Body: ErrorE_JOBS_PAYLOAD_TOO_LARGE: data over 32 KB.Body: ErrorE_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.Body: ErrorE_JOBS_BACKLOG (the list has 50,000 waiting), the per-site rate limit, or too many failed keys from this IP.Body: ErrorCancel a waiting job
/api/site/v1/jobs/cancelCancel 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.
Request body
{
"list": "booking-reminders",
"key": "remind-42"
}Spend a one-click owner sign-in ticket
/api/site/v1/owner-sign-inThe 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.
Request body
| Field | Type and limits | What it is |
|---|---|---|
ticketRequired | string | |
version | 2 |
{
"ticket": "Qm9vay1hLXRpY2tldC1mb3ItdGhlLW93bmVyLXNpZ25pbg",
"version": 2
}Responses
object{
"status": 200,
"data": {
"ownerId": "b3duZXItaWQtZm9yLXRoaXMtc2l0ZS0wMDAwMDAwMDAwMA"
}
}E_OWNER_TICKET_INVALID: unknown, spent, expired, another site’s, for the other key kind, or no longer an owner.Body: ErrorThe site’s prices
/api/site/v1/paymentsThe mode (test or live, decided by the key and the account, never the site), whether the site can take payments, and its active prices.
Responses
object{
"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
}
]
}
}ErrorE_NO_SITE_KEYStart a checkout
/api/site/v1/payments/checkoutStarts 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.
Request body
| Field | Type and limits | What it is |
|---|---|---|
priceRequired | stringpattern ^[a-z0-9][a-z0-9-]{0,62}$ | |
quantity | integer1 to 99 | |
email | stringup to 191 characters, email | |
name | stringup to 191 characters | |
successPathRequired | stringup to 400 characters, pattern ^\/(?![/\\])[^\s\\\u0000-\u001f\u007f]*$ | |
cancelPathRequired | stringup to 400 characters, pattern ^\/(?![/\\])[^\s\\\u0000-\u001f\u007f]*$ | |
metadata | object | |
customerRef | stringup to 128 characters | |
idempotencyKey | stringpattern ^[A-Za-z0-9_-]{16,64}$ | |
gateway | stringpattern ^[a-z][a-z0-9_-]{1,39}$ | |
noTrial | boolean |
{
"price": "pro-monthly",
"successPath": "/thanks/",
"cancelPath": "/pricing/",
"idempotencyKey": "attempt_0123456789abcdef"
}Responses
{id, mode, embedded}, or {id, mode, embedded: null, paid: true}.Body: objectErrorE_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.Body: ErrorE_SITE_PAYMENTS_EMAIL_REQUIRED, E_SITE_PAYMENTS_GATEWAY_UNAVAILABLE, E_SITE_PAYMENTS_QUANTITY.Body: ErrorE_SITE_PAYMENTS_UNAVAILABLE: the same key again is safe.Body: ErrorWhat happened to a checkout
/api/site/v1/payments/checkout/{id}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.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
idRequired | path | stringpattern ^pcs_[a-f0-9]{24}$ | The checkout id (pcs_…). |
The ways to pay a price
/api/site/v1/payments/checkout/gatewaysThe payment providers the buyer may choose for this price: active, take its currency, and its free trial when it has one.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
priceRequired | query | stringpattern ^[a-z0-9][a-z0-9-]{0,62}$ |
The currencies this site can charge in
/api/site/v1/payments/currenciesThe currencies a new price may use, from the site’s active payment providers.
Responses
objectErrorE_NO_SITE_KEYA buyer’s memberships
/api/site/v1/payments/membershipsThe subscriptions bought with this email. Access follows accessEndsAt (hasAccess), never status. Show them only to that buyer, once their email is verified.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
emailRequired | query | stringup to 191 characters, email |
Responses
{mode, memberships}.Body: objectErrorE_NO_SITE_KEYCancel a membership
/api/site/v1/payments/memberships/{id}/cancelFor the site’s own cancel button. atPeriodEnd keeps access until the period ends.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
idRequired | path | string | The membership: its subscription id, or our mem_… id. |
Request body
| Field | Type and limits | What it is |
|---|---|---|
atPeriodEnd | boolean |
Add a price
/api/site/v1/payments/pricesEditor key only. The same key with the same terms answers 200 with created: false. 60 an hour per site.
Request body
| Field | Type and limits | What it is |
|---|---|---|
keyRequired | stringpattern ^[a-z0-9][a-z0-9-]{0,62}$ | |
nameRequired | string1 to 120 characters | |
description | string | nullup to 500 characters | |
amountCentsRequired | integer1 to 99999999 | |
currencyRequired | stringpattern ^[A-Za-z]{3}$ | |
intervalRequired | "one_time" | "month" | "year" | |
intervalCount | integer1 to 12 | |
trialDays | integer0 to 365 |
Responses
{price, created: false}.Body: object{price, created: true}.Body: objectErrorE_NO_SITE_KEYE_SITE_PAYMENTS_EDITOR_ONLY: a price is added with the editor key.Body: ErrorE_PRICE_KEY_TAKEN: the key exists with other terms, or the owner archived it.Body: ErrorErrorThe agency’s client plans (house website)
/api/site/v1/plansThe plans an agency sells to its clients, for its own marketing website. Any other site gets 404. Editor or live key; never cached.
Download this site’s template
/api/site/v1/template/downloadThe 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.
Responses
ErrorE_NO_SITE_KEYPublic site
No credential. The site is the one whose address (Host) the request arrives on. Read only.
Get the platform rules for coding agents
/api/public/site/v1/agent-docsMarkdown, 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).
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
template | query | stringup to 32 characters | astro-app or astro-static. Anything else answers 404. |
Responses
X-Agent-Docs-Template names the template).Body: string (text/markdown)# Site platform rules (vf2297aa7)
# Rules for AI agents working in this app
…ErrorE_AGENT_DOCS_UNAVAILABLE: the docs could not be rendered.Body: ErrorGet one agent topic doc
/api/public/site/v1/agent-docs/{topic}Markdown for one topic, e.g. site-api, security, auth, database (.md optional; core is the core doc).
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
topicRequired | path | stringpattern ^[a-z0-9][a-z0-9-]{0,63}(\.md)?$ | Topic name, lowercase letters, digits and dashes, optional .md. |
template | query | stringup to 32 characters | astro-app or astro-static. |
Responses
Get the site’s public brand (template path)
/api/public/site/v1/initial-dataSame answer as GET /api/public/site/v1/site. This is the path the EzyStudio templates call.
Responses
PublicSiteErrorThe site’s prices, for its pages
/api/public/site/v1/paymentsThe catalogue for a site’s Buy buttons. No credential: the site comes from the address it’s called on.
Responses
objectErrorStart a checkout from a page
/api/public/site/v1/payments/checkoutStarts 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.
Request body
| Field | Type and limits | What it is |
|---|---|---|
priceRequired | stringpattern ^[a-z0-9][a-z0-9-]{0,62}$ | |
quantity | integer1 to 99 | |
successPathRequired | stringup to 400 characters, pattern ^\/(?![/\\])[^\s\\\u0000-\u001f\u007f]*$ | |
cancelPathRequired | stringup to 400 characters, pattern ^\/(?![/\\])[^\s\\\u0000-\u001f\u007f]*$ | |
email | stringup to 191 characters, email | |
idempotencyKey | stringpattern ^[A-Za-z0-9_-]{16,64}$ | |
gateway | stringpattern ^[a-z][a-z0-9_-]{1,39}$ | |
noTrial | boolean |
Responses
{id, mode, embedded}, or {id, mode, embedded: null, paid: true}.Body: objectErrorE_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.Body: ErrorE_SITE_PAYMENTS_EMAIL_REQUIRED, E_SITE_PAYMENTS_GATEWAY_UNAVAILABLE, E_SITE_PAYMENTS_QUANTITY.Body: ErrorE_SITE_PAYMENTS_UNAVAILABLE: the same key again is safe.Body: ErrorA checkout’s status
/api/public/site/v1/payments/checkout/{id}{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.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
idRequired | path | stringpattern ^pcs_[a-f0-9]{24}$ | The checkout id (pcs_…). |
Responses
{status}.Body: objectErrorThe ways to pay a price, for its pages
/api/public/site/v1/payments/gatewaysAs the Site API’s, for a static site’s Buy button.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
priceRequired | query | stringpattern ^[a-z0-9][a-z0-9-]{0,62}$ |
Responses
{mode, gateways: [{slug, name}]}.Body: objectErrorGet the plans an agency sells, on its own website
/api/public/site/v1/plansOn 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.
Responses
objectError{
"status": 404,
"errors": [
{
"message": "Not found.",
"code": "E_NOT_FOUND"
}
]
}Get the site’s public brand
/api/public/site/v1/siteThe 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).
Responses
PublicSite{
"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
}
}Error{
"status": 404,
"errors": [
{
"message": "No site is served on this host.",
"code": "E_SITE_NOT_FOUND"
}
]
}App Store
The public App Store catalogue. No sign-in.
Browse or search apps
/api/public/store/v1/appsLive apps only. q searches names and descriptions (unmatched searches are counted as requests for new apps).
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
q | query | stringup to 120 characters | Search words. |
category | query | stringup to 48 characters, pattern ^[a-z0-9-]+$ | A category slug. |
cursor | query | stringup to 512 characters | meta.nextCursor from the previous page. |
limit | query | integer1 to 60 | Page size. |
Responses
object{
"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
}
}
}ValidationErrorGet a listing
/api/public/store/v1/apps/{slug}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.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
slugRequired | path | stringup to 96 characters, pattern ^[a-z0-9][a-z0-9-]*$ | The listing slug, e.g. salon-booking. |
Responses
objectError{
"status": 404,
"errors": [
{
"message": "We couldn't find that app.",
"code": "APP_NOT_FOUND"
}
]
}List a listing’s reviews
/api/public/store/v1/apps/{slug}/reviewsPublished reviews from verified buyers, with the developer’s reply.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
slugRequired | path | stringup to 96 characters, pattern ^[a-z0-9][a-z0-9-]*$ | The listing slug, e.g. salon-booking. |
sort | query | "recent" | "highest" | "lowest" | recent (default), highest or lowest. |
cursor | query | stringup to 512 characters | meta.nextCursor. |
limit | query | integer1 to 50 | Page size. |
Responses
object{
"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
}
}
}ErrorValidationErrorList categories
/api/public/store/v1/categoriesStore categories with live app counts. Cached 60 seconds with an ETag.
Responses
object[]{
"status": 200,
"data": [
{
"slug": "bookings",
"name": "Bookings",
"icon": "calendar-check",
"tone": "bookings",
"appsCount": 8
}
]
}List collections
/api/public/store/v1/collectionsThe store’s collections (a title, a subtitle and the category they show).
Responses
object[]{
"status": 200,
"data": [
{
"key": "bookings",
"categorySlug": "bookings",
"icon": "calendar-days",
"tone": "a",
"title": "Take bookings",
"subtitle": "Salons, clinics, spas, tutors, hotels and more"
}
]
}Get the featured shelf
/api/public/store/v1/featuredApp of the week and the featured apps.
Responses
object{
"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
}
]
}
}Plans
Public plan catalogue (the pricing page).
Announcement bar for a page
/api/public/banner/v1The 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.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
path | query | stringup to 300 characters | The page path, e.g. /pricing (default /). |
surface | query | "site" | "app" | site (default): marketing pages. app: the customer app. |
Responses
objectValidationErrorList plans
/api/public/plansThe 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).
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
audience | query | "account" | "agency" | account (default): customer plans. agency: partner plans. |
Responses
objectValidationErrorPlan cards and comparison table
/api/public/pricing/v1Each 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.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
audience | query | "account" | "agency" | account (default): customer plans. agency: partner plans. |
locale | query | stringpattern ^[a-z]{2}(-[a-z]{2})?$ | Text locale, e.g. en (default) or bn; missing text falls back to English. |
Responses
objectValidationErrorPartners
Public partner directory and “Hire an expert” leads.
Get a freelance expert
/api/public/experts/{slug}One approved, listed expert (card + bio).
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
slugRequired | path | stringup to 64 characters | Expert slug. |
Responses
objectErrorRead a sale link offer
/api/public/join/{token}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).
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
tokenRequired | path | string8 to 32 characters | The link token (/join/<token>). |
Responses
objectError{
"status": 402,
"errors": [
{
"message": "This feature is not included in your plan.",
"code": "E_RESOURCE_LIMIT_EXCEEDED"
}
]
}Error{
"status": 404,
"errors": [
{
"message": "This link doesn’t exist.",
"code": "E_NOT_FOUND"
}
]
}Ask for an expert
/api/public/leads“Hire an expert”: matches up to 3 partners. Anonymous or signed in.
Request body
| Field | Type and limits | What it is |
|---|---|---|
needRequired | "finish_app" | "from_scratch" | "design_refresh" | "ongoing_care" | |
budgetBandRequired | "lt_500" | "500_1500" | "1500_5000" | "gt_5000" | "unsure" | |
timelineRequired | "asap" | "two_weeks" | "month" | "no_rush" | |
languageRequired | stringpattern ^[a-z]{2}(-[A-Z]{2})?$ | |
country | stringpattern ^[A-Z]{2}$ | |
details | stringup to 4000 characters | |
siteId | integer0 or more | |
contactName | string1 to 120 characters | |
contactEmail | stringup to 254 characters, email | |
partner | object | |
partner.Required | "agency" | "expert" | |
partner.Required | stringup to 64 characters |
{
"need": "finish_app",
"budgetBand": "500_1500",
"timeline": "month",
"language": "en"
}Responses
objectValidationErrorcode: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: ErrorGet your lead
/api/public/leads/{publicId}The requester’s lead and its matches.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
publicIdRequired | path | string16 to 16 characters | The lead’s public id. |
token | query | stringup to 128 characters | The requester’s token, when not signed in. |
Responses
objectErrorContact a matched partner
/api/public/leads/{publicId}/contactOffers the lead to one of its matched partners.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
publicIdRequired | path | string16 to 16 characters | The lead’s public id. |
Request body
| Field | Type and limits | What it is |
|---|---|---|
token | stringup to 128 characters | |
partnerRequired | stringup to 64 characters | |
type | "agency" | "expert" |
{
"partner": "casa-pixel"
}Responses
objectErrorValidationErrorcode: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: ErrorList partner levels
/api/public/partner-levelsLadder tiers (Starter → Rising → Pro → Elite, Founding): points floor, referral %, perks, and founding spots left.
Responses
objectList listed partners
/api/public/partnersThe expert directory.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
country | query | stringpattern ^[A-Z]{2}$ | |
language | query | stringpattern ^[a-z]{2}(-[A-Z]{2})?$ | |
type | query | "agency" | "expert" | |
skill | query | string1 to 40 characters | |
level | query | "starter" | "rising" | "pro" | "elite" | "founding" | |
cursor | query | stringup to 512 characters | |
limit | query | integer1 to 50 |
Responses
objectValidationErrorGet a partner
/api/public/partners/{slug}| Parameter | In | Type and limits | What it is |
|---|---|---|---|
slugRequired | path | stringup to 64 characters | Partner slug. |
Responses
objectErrorRecord a click on an agency client's referral link
/api/public/referral-program/v1/clickAn 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.
Request body
{
"code": "MAYA-7K2Q",
"path": "/pricing"
}Responses
objectErrorValidationErrorcode: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: ErrorCheck an agency client's referral code
/api/public/referral-program/v1/codes/{code}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.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
codeRequired | path | string2 to 32 characters | Referral code (case-insensitive). |
Record a referral link click
/api/public/referrals/v1/clickA ?ref=CODE landing (landing path + salted IP hash, no other PII).
Request body
{
"code": "MAYA-7K2Q",
"path": "/pricing"
}Responses
objectValidationErrorcode: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: ErrorCheck a referral code
/api/public/referrals/v1/codes/{code}{valid, reason (not_found|inactive|self), referrer, perk}.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
codeRequired | path | stringup to 32 characters | Referral code (case-insensitive). |
Responses
objectcode: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: ErrorReferral programme terms
/api/public/referrals/v1/programTiers, commission months, the welcome perk and the points rules.
Responses
objectPlatform
Other public reads: brand, email assets, app media files, the visitor’s default language.
Get an app media file
/api/public/app-media/v1/{path}Listing images while EzyStudio stores media itself (with object storage, media URLs point at the bucket). Bundles are never served.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
pathRequired | path | string | The storage key, e.g. apps/42/screenshot/01jb2x5m3a.png. |
Responses
string (image/*)ErrorGet the host’s brand
/api/public/brandThe agency whose dashboard host this is: EzyStudio itself, or a white-label partner’s brand.
Responses
object{
"status": 200,
"data": {
"isMaster": true,
"name": "EzyStudio",
"brand": null,
"clientSitePattern": null
}
}Get an email image
/api/public/email-assets/{file}Images used in EzyStudio emails (brand-mark.png).
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
fileRequired | path | string | File name, e.g. brand-mark.png. |
Responses
string (image/png)Download your data export
/api/public/privacy/v1/exports/{token}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.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
tokenRequired | path | stringup to 80 characters | The token from the email link. |
Responses
Download a version’s frozen source (staff link)
/api/public/review-source/v1/{token}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.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
tokenRequired | path | string | The signed link token. |
Responses
string (application/zip)ErrorDownload a site database backup
/api/public/site-database/v1/backups/{ticket}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.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
ticketRequired | path | string | The sealed ticket from the download link. |
Responses
string (application/octet-stream)E_SITE_DATABASE_BACKUP_LINK: the link is not valid, was already used, has expired, or the backup is gone.Body: Errorcode: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: ErrorSigned file link (local development)
/api/public/site-files/v1/files/{token}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”.
| Parameter | In | Type and limits | What it is |
|---|---|---|---|
tokenRequired | path | string | Sealed, expiring link token. |
Default language for this visitor
/api/public/v1/localeThe 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.
Responses
object{
"locale": "de",
"country": "DE"
}Schemas
Envelope
Every JSON success answer: {status, data}, plus message on writes (start/macros/response.ts sendFormatted).
| Field | Type and limits | What it is |
|---|---|---|
statusAlways | integer | The HTTP status, repeated. |
data | any | The payload (see each operation). |
message | string | Words for people, in the caller’s language. Never branch on it. |
Error
Every error answer: {status, errors: [{message, code}]} (sendError). Branch on code, never on message.
| Field | Type and limits | What it is |
|---|---|---|
status | integer | |
errorsAlways | object[] | |
errors[].Always | string | |
errors[]. | string | Machine code, e.g. E_INVALID_SITE_KEY. |
errors[]. | object | Structured detail for some codes. |
ValidationError
422 from a Vine validator: one entry per failed rule (no status key in this shape).
| Field | Type and limits | What it is |
|---|---|---|
errorsAlways | object[] | |
errors[].Always | string | |
errors[].Always | string | The failed rule, e.g. maxLength, required, email. |
errors[].Always | string | The field path, e.g. media.0.altText. |
errors[]. | object |
CursorMeta
Keyset pages: pass nextCursor back as cursor. There are no totals.
| Field | Type and limits | What it is |
|---|---|---|
nextCursorAlways | string | null | |
hasNextPageAlways | boolean |
PublicSite
The public projection of a site (site_public.controller.ts publicSiteProjection). Never the account, the owner or a setting.
| Field | Type and limits | What it is |
|---|---|---|
idAlways | integer | |
nameAlways | string | |
slugAlways | string | |
kindAlways | "static" | "app" | static = website, app = logins, payments, database. |
templateAlways | string | null | astro-app or astro-static. |
urlAlways | string | null | The site’s primary address (custom domain, else <slug>.ezystudio.app). |
logoAlways | string | null | |
favIconAlways | string | null | |
brandColorAlways | string | null |
AppManifest
| Field | Type and limits | What it is |
|---|---|---|
name | string1 to 80 characters | The app’s name on the store. |
categorySlug | stringup to 48 characters | One of GET /api/public/store/v1/categories (bookings, food, selling, learning, events, listings, business, websites). |
tagline | string | nullup to 60 characters | One line under the name. Required to submit. |
description | string | nullup to 5000 characters | What the app does, in plain words. At least 40 characters to submit. |
dataNote | string | nullup to 2000 characters | What the app stores about the owner’s customers and why. Shown under “Your data”. At least 20 characters to submit. |
supportEmail | string | nullup to 254 characters, email | Where buyers reach you. Falls back to your developer profile’s support email. |
languages | string[] | nullup to 30 items | Language codes the app works in, e.g. en, es. |
plans | "website" | "app" | "business"[]up to 3 items | EzyStudio plans the app works on. At least one to submit. |
pricingModel | "free" | "one_time" | "monthly" | "included" | free, one_time or monthly. included is for EzyStudio’s own apps only (422 otherwise). |
priceCents | integer | null0 to 99900 | 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 | "USD" | USD only for now. |
customization | object | What a buyer’s one-sentence customization may change. Defaults: colors, text and fields on, layout off. |
customization. | boolean | Colors and fonts. |
customization. | boolean | Names, labels and copy. |
customization. | boolean | Form fields. |
customization. | boolean | Layout and order of sections. |
customization. | stringup to 500 characters | An example sentence for buyers. |
included | any[]up to 50 items | Listing bullets: what the app includes (strings or objects, up to 50). |
features | any[]up to 50 items | Feature cards on the listing, e.g. {icon, group, title, description} (up to 50). |
setupSteps | any[]up to 50 items | Steps shown while the app installs. Strings or {title} / {label} objects; the first 6 are used. |
madeFor | any[]up to 50 items | Kinds of businesses it is for (up to 50). |
trySaying | any[]up to 50 items | Example customization sentences (up to 50). |
changelog | string | nullup to 2000 characters | 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 | string | nullup to 2000 characters | 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 | boolean | True when nothing is removed or changed in a way customers lose (instead of customerImpact). |
aiUpdateNotes | string | nullup to 8000 characters | 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 | stringpattern ^\d{1,4}\.\d{1,4}(\.\d{1,4})?$ | 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 | object | null | 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. |
repo.Always | integer0 to 9007199254740991 | GitHub’s installation id, from the GitHub connection’s installations. |
repo.Always | integer0 to 9007199254740991 | GitHub’s repository id (one the installation covers). |
repo.Always | string1 to 255 characters | The branch to take the code from. |
repo. | boolean | Check the branch’s latest commit again even when the pick is unchanged. |
media | object[]up to 10 items | Replaces the whole list. Up to 1 icon, 1 cover and 8 screenshots; order = position within each kind. |
media[].Always | "icon" | "cover" | "screenshot" | icon, cover or screenshot. |
media[]. | stringup to 512 characters | The key from the presign answer (preferred). |
media[]. | stringup to 1024 characters, uri | Only a URL this API returned for one of this app’s files. New outside URLs are refused. |
media[]. | stringup to 250 characters | Required on every image to submit. |
media[]. | stringup to 120 characters | Optional caption. |
media[]. | integer1 to 20000 | Pixels. |
media[]. | integer1 to 20000 | Pixels. |
This page and openapi.json are rebuilt from the API's routes and validators, so they change when the API does (API version 0.0.1).