Skip to content
Developer docs

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}, plus message on writes. Errors are {status, errors: [{message, code}]}: branch on code, never on message (it is translated).
  • Validation errors (422) list every failed rule: {errors: [{message, rule, field}]}.
  • Lists are keyset pages: pass meta.nextCursor back as cursor. There are no totals.
  • Over a limit you get 429 with a Retry-After header.
CredentialHeaderFormat
Site KeyAuthorization: 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

POST/api/site/v1/email-check
Site KeyOn the site's own address

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.

Request body
FieldType and limitsWhat it is
emailRequiredstringemail
Example
{
  "email": "maya@lumahair.test"
}
Responses
200
Whether to accept the address.Body: object
Example
{
  "status": 200,
  "data": {
    "accept": true
  }
}
401
E_NO_SITE_KEY, E_BAD_SITE_KEY, E_INVALID_SITE_KEY, E_SITE_KEY_REVOKED.Body: Error

Relay an account email

POST/api/site/v1/emails
Site KeyOn the site's own addressLimit: 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.

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.

Request body
FieldType and limitsWhat 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.
tostringup to 254 characters, emailThe recipient.
pathstringup 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.
expiresInMinutesinteger1 to 10080Shown in the email (“this link expires in …”). Up to one week.
subjectstring1 to 120 charactersRequired for notification.
textstring1 to 2000 charactersPlain text, escaped when rendered. Required for notification.
codestringpattern ^\d{6}$sign_in_code only: exactly 6 digits, shown in the email.
Example
{
  "kind": "password_reset",
  "to": "maya@lumahair.test",
  "path": "/account/reset?token=8f2c…",
  "expiresInMinutes": 30
}
Responses
202
Accepted (queued, or dropped by the per-recipient limit: same answer).Body: object
Example
{
  "status": 202,
  "message": "Email accepted for delivery.",
  "data": {
    "accepted": true
  }
}
401
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
Example
{
  "status": 401,
  "errors": [
    {
      "message": "Invalid Site Key.",
      "code": "E_INVALID_SITE_KEY"
    }
  ]
}
409
E_SITE_HAS_NO_HOST: the site has no address to build the link on.Body: Error
422
Validation failed, or E_SITE_EMAIL_RECIPIENT (a notification to someone other than the site owner).Body: ValidationError | Error
429
Rate limited. The body has no code: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: Error

List files

GET/api/site/v1/files
Site KeyOn the site's own address

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.

ParameterInType and limitsWhat it is
beforequerystringup to 40 charactersThe next value of the previous page.
limitqueryinteger1 to 1001 to 100. Default 50.
Responses
200
{files, next}; next is null on the last page.Body: object
401
Site Key errors.Body: Error

Get one file

GET/api/site/v1/files/{fileId}
Site KeyOn the site's own address

The file’s details (never its bytes: ask for a link).

ParameterInType and limitsWhat it is
fileIdRequiredpathstringpattern ^file_[0-9a-z]{26}$The file id (file_…).
Responses
200
{file}.Body: object
401
Site Key errors.Body: Error
404
E_FILE_NOT_FOUND.Body: Error

Complete a file upload

POST/api/site/v1/files/{fileId}/complete
Site KeyOn the site's own address

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).

ParameterInType and limitsWhat it is
fileIdRequiredpathstringpattern ^file_[0-9a-z]{26}$The file id (file_…).
Responses
200
The file, now ready.Body: object
401
Site Key errors.Body: Error
403
E_STORAGE_UNAVAILABLE.Body: Error
404
E_FILE_NOT_FOUND: no such file on this site (and key kind).Body: Error
409
E_FILE_NOT_UPLOADED: nothing stored yet. PUT the file, then call again.Body: Error
410
E_UPLOAD_EXPIRED: the grant expired (1 hour). Ask for a new upload.Body: Error
422
E_FILE_SIZE_MISMATCH: the stored file was not the declared size and was deleted.Body: Error

Delete a file

POST/api/site/v1/files/{fileId}/delete
Site KeyOn the site's own address

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).

ParameterInType and limitsWhat it is
fileIdRequiredpathstringpattern ^file_[0-9a-z]{26}$The file id (file_…).
Responses
200
Deleted.Body: object
401
Site Key errors.Body: Error
404
E_FILE_NOT_FOUND.Body: Error

Get a signed link to a file

POST/api/site/v1/files/{fileId}/url
Site KeyOn the site's own address

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.

ParameterInType and limitsWhat it is
fileIdRequiredpathstringpattern ^file_[0-9a-z]{26}$The file id (file_…).
Request body
FieldType and limitsWhat it is
expiresIninteger1 to 604800Seconds the link works: 60 to 604800 (7 days). Default 3600.
downloadbooleantrue = the browser saves the file instead of showing it.
Example
{
  "expiresIn": 600
}
Responses
200
The link.Body: object
Example
{
  "status": 200,
  "data": {
    "url": "https://files.example/s42/live/01jbz3k8….pdf?token=…&expires=1793102400",
    "expiresAt": "2026-10-27T10:10:00.000Z"
  }
}
401
Site Key errors.Body: Error
403
E_STORAGE_UNAVAILABLE.Body: Error
404
E_FILE_NOT_FOUND.Body: Error
409
E_FILE_NOT_READY: call complete first.Body: Error

Start a file upload

POST/api/site/v1/files/uploads
Site KeyOn the site's own addressLimit: 300 upload grants a minute per site; 600 calls a minute per site and key kind.

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).

Request body
FieldType and limitsWhat it is
nameRequiredstring1 to 400 charactersDisplay name, e.g. cv.pdf. Paths and control characters are removed; up to 200 characters are kept.
contentTypeRequiredstring3 to 127 charactersThe file’s MIME type, e.g. image/jpeg. Must be on the allow-list.
sizeRequiredinteger0 or moreExact 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>.
Example
{
  "name": "cv.pdf",
  "contentType": "application/pdf",
  "size": 482113
}
Responses
201
Granted: PUT the file’s bytes to upload.url (on the site’s own address) with upload.headers, then call complete.Body: 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
E_NO_SITE_KEY, E_BAD_SITE_KEY, E_INVALID_SITE_KEY, E_SITE_KEY_REVOKED.Body: Error
402
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
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
E_STORAGE_OFF (turned off for this site) or E_STORAGE_UNAVAILABLE (no storage for this account).Body: Error
422
E_FILE_TYPE, E_FILE_EMPTY, E_FILE_TOO_LARGE (over 100 MB), or validation.Body: Error
429
The per-site rate limit, or too many failed keys from this IP.Body: Error

Storage used and the limit

GET/api/site/v1/files/usage
Site KeyOn the site's own address

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.

Responses
200
Usage.Body: object
Example
{
  "status": 200,
  "data": {
    "usedBytes": 1288490188,
    "limitBytes": 5368709120,
    "onExceed": "block",
    "maxFileBytes": 104857600
  }
}
401
Site Key errors.Body: Error
403
E_STORAGE_OFF or E_STORAGE_UNAVAILABLE.Body: Error

Send background jobs

POST/api/site/v1/jobs
Site KeyOn the site's own addressLimit: 600 calls a minute per site and key kind; 50,000 waiting jobs per list.

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).

Request body

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
Accepted. One job → the job; a batch → {jobs: [...]}.Body: object
Example
{
  "status": 202,
  "message": "Job accepted.",
  "data": {
    "id": "job_abcdefghijklmnopqrstuvwxyz",
    "list": "booking-reminders",
    "runAt": "2026-11-01T09:00:00.000Z",
    "deduplicated": false
  }
}
401
E_NO_SITE_KEY, E_BAD_SITE_KEY, E_INVALID_SITE_KEY, E_SITE_KEY_REVOKED.Body: Error
403
E_JOBS_EDITOR_KEY: jobs are not sent from the editor.Body: Error
409
E_JOBS_NOT_AN_APP: a website (static) has no background jobs.Body: Error
413
E_JOBS_PAYLOAD_TOO_LARGE: data over 32 KB.Body: Error
422
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.Body: Error
429
E_JOBS_BACKLOG (the list has 50,000 waiting), the per-site rate limit, or too many failed keys from this IP.Body: Error

Cancel a waiting job

POST/api/site/v1/jobs/cancel
Site KeyOn the site's own address

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.

Request body

Example
{
  "list": "booking-reminders",
  "key": "remind-42"
}
Responses
200
cancelled: false when no such job exists.Body: object
Example
{
  "status": 200,
  "message": "Cancelled. It won't run.",
  "data": {
    "cancelled": true
  }
}
401
E_NO_SITE_KEY, E_BAD_SITE_KEY, E_INVALID_SITE_KEY, E_SITE_KEY_REVOKED.Body: Error
403
E_JOBS_EDITOR_KEY.Body: Error
409
E_JOBS_NOT_CANCELLABLE: it is running or finished.Body: Error

Spend a one-click owner sign-in ticket

POST/api/site/v1/owner-sign-in
Site KeyOn the site's own address

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.

Request body
FieldType and limitsWhat it is
ticketRequiredstring
version2
Example
{
  "ticket": "Qm9vay1hLXRpY2tldC1mb3ItdGhlLW93bmVyLXNpZ25pbg",
  "version": 2
}
Responses
200
An opaque id for the account owner. Never an email or name.Body: object
Example
{
  "status": 200,
  "data": {
    "ownerId": "b3duZXItaWQtZm9yLXRoaXMtc2l0ZS0wMDAwMDAwMDAwMA"
  }
}
401
E_NO_SITE_KEY, E_BAD_SITE_KEY, E_INVALID_SITE_KEY, E_SITE_KEY_REVOKED.Body: Error
404
E_OWNER_TICKET_INVALID: unknown, spent, expired, another site’s, for the other key kind, or no longer an owner.Body: Error

The site’s prices

GET/api/site/v1/payments
Site KeyOn the site's own address

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.

Responses
200
The catalogue.Body: 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
The Site Key is missing or wrong.Body: Error
Example
E_NO_SITE_KEY

Start a checkout

POST/api/site/v1/payments/checkout
Site KeyOn the site's own address

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.

Request body
FieldType and limitsWhat it is
priceRequiredstringpattern ^[a-z0-9][a-z0-9-]{0,62}$
quantityinteger1 to 99
emailstringup to 191 characters, email
namestringup to 191 characters
successPathRequiredstringup to 400 characters, pattern ^\/(?![/\\])[^\s\\\u0000-\u001f\u007f]*$
cancelPathRequiredstringup to 400 characters, pattern ^\/(?![/\\])[^\s\\\u0000-\u001f\u007f]*$
metadataobject
customerRefstringup to 128 characters
idempotencyKeystringpattern ^[A-Za-z0-9_-]{16,64}$
gatewaystringpattern ^[a-z][a-z0-9_-]{1,39}$
noTrialboolean
Example
{
  "price": "pro-monthly",
  "successPath": "/thanks/",
  "cancelPath": "/pricing/",
  "idempotencyKey": "attempt_0123456789abcdef"
}
Responses
201
{id, mode, embedded}, or {id, mode, embedded: null, paid: true}.Body: object
404
No such price.Body: Error
409
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.Body: Error
422
A field is not valid, or E_SITE_PAYMENTS_EMAIL_REQUIRED, E_SITE_PAYMENTS_GATEWAY_UNAVAILABLE, E_SITE_PAYMENTS_QUANTITY.Body: Error
502
E_SITE_PAYMENTS_UNAVAILABLE: the same key again is safe.Body: Error

What happened to a checkout

GET/api/site/v1/payments/checkout/{id}
Site KeyOn the site's own address

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.

ParameterInType and limitsWhat it is
idRequiredpathstringpattern ^pcs_[a-f0-9]{24}$The checkout id (pcs_…).
Responses
200
The checkout.Body: object
401
The Site Key is missing or wrong.Body: Error
Example
E_NO_SITE_KEY
404
No such checkout.Body: Error

The ways to pay a price

GET/api/site/v1/payments/checkout/gateways
Site KeyOn the site's own address

The payment providers the buyer may choose for this price: active, take its currency, and its free trial when it has one.

ParameterInType and limitsWhat it is
priceRequiredquerystringpattern ^[a-z0-9][a-z0-9-]{0,62}$
Responses
200
{mode, gateways: [{slug, name}]}.Body: object
401
The Site Key is missing or wrong.Body: Error
Example
E_NO_SITE_KEY
404
No such price.Body: Error

The currencies this site can charge in

GET/api/site/v1/payments/currencies
Site KeyOn the site's own address

The currencies a new price may use, from the site’s active payment providers.

Responses
200
The currencies.Body: object
401
The Site Key is missing or wrong.Body: Error
Example
E_NO_SITE_KEY

A buyer’s memberships

GET/api/site/v1/payments/memberships
Site KeyOn the site's own address

The subscriptions bought with this email. Access follows accessEndsAt (hasAccess), never status. Show them only to that buyer, once their email is verified.

ParameterInType and limitsWhat it is
emailRequiredquerystringup to 191 characters, email
Responses
200
{mode, memberships}.Body: object
401
The Site Key is missing or wrong.Body: Error
Example
E_NO_SITE_KEY

Cancel a membership

POST/api/site/v1/payments/memberships/{id}/cancel
Site KeyOn the site's own address

For the site’s own cancel button. atPeriodEnd keeps access until the period ends.

ParameterInType and limitsWhat it is
idRequiredpathstringThe membership: its subscription id, or our mem_… id.
Request body
FieldType and limitsWhat it is
atPeriodEndboolean
Responses
200
{membership}.Body: object
401
The Site Key is missing or wrong.Body: Error
Example
E_NO_SITE_KEY
404
No such membership.Body: Error

Add a price

POST/api/site/v1/payments/prices
Site KeyOn the site's own address

Editor key only. The same key with the same terms answers 200 with created: false. 60 an hour per site.

Request body
FieldType and limitsWhat it is
keyRequiredstringpattern ^[a-z0-9][a-z0-9-]{0,62}$
nameRequiredstring1 to 120 characters
descriptionstring | nullup to 500 characters
amountCentsRequiredinteger1 to 99999999
currencyRequiredstringpattern ^[A-Za-z]{3}$
intervalRequired"one_time" | "month" | "year"
intervalCountinteger1 to 12
trialDaysinteger0 to 365
Responses
200
That price already exists: {price, created: false}.Body: object
201
{price, created: true}.Body: object
401
The Site Key is missing or wrong.Body: Error
Example
E_NO_SITE_KEY
403
E_SITE_PAYMENTS_EDITOR_ONLY: a price is added with the editor key.Body: Error
409
E_PRICE_KEY_TAKEN: the key exists with other terms, or the owner archived it.Body: Error
422
A field is not valid.Body: Error

The agency’s client plans (house website)

GET/api/site/v1/plans
Site KeyOn the site's own address

The plans an agency sells to its clients, for its own marketing website. Any other site gets 404. Editor or live key; never cached.

Responses
200
The plans.Body: object
401
The Site Key is missing or wrong.Body: Error
Example
E_NO_SITE_KEY
404
Not an agency’s house website.Body: Error

Download this site’s template

GET/api/site/v1/template/download
Site KeyOn the site's own address

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.

Responses
200
The template as a zip.
401
The Site Key is missing or wrong.Body: Error
Example
E_NO_SITE_KEY

Public site

No credential. The site is the one whose address (Host) the request arrives on. Read only.

Get the platform rules for coding agents

GET/api/public/site/v1/agent-docs
No credentialOn the site's own addressLimit: 300 a minute per caller.

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).

ParameterInType and limitsWhat it is
templatequerystringup to 32 charactersastro-app or astro-static. Anything else answers 404.
Responses
200
The core doc (X-Agent-Docs-Template names the template).Body: string (text/markdown)
Example
# Site platform rules (vf2297aa7)

# Rules for AI agents working in this app
…
304
Not modified (ETag matched).
404
Unknown template.Body: Error
503
E_AGENT_DOCS_UNAVAILABLE: the docs could not be rendered.Body: Error

Get one agent topic doc

GET/api/public/site/v1/agent-docs/{topic}
No credentialOn the site's own addressLimit: 300 a minute per caller.

Markdown for one topic, e.g. site-api, security, auth, database (.md optional; core is the core doc).

ParameterInType and limitsWhat it is
topicRequiredpathstringpattern ^[a-z0-9][a-z0-9-]{0,63}(\.md)?$Topic name, lowercase letters, digits and dashes, optional .md.
templatequerystringup to 32 charactersastro-app or astro-static.
Responses
200
The topic doc.Body: string (text/markdown)
304
Not modified (ETag matched).
404
E_AGENT_DOC_NOT_FOUND: no such topic for this template.Body: Error
Example
{
  "status": 404,
  "errors": [
    {
      "message": "No such agent doc.",
      "code": "E_AGENT_DOC_NOT_FOUND"
    }
  ]
}
503
E_AGENT_DOCS_UNAVAILABLE.Body: Error

Get the site’s public brand (template path)

GET/api/public/site/v1/initial-data
No credentialOn the site's own addressLimit: 300 a minute per caller.

Same answer as GET /api/public/site/v1/site. This is the path the EzyStudio templates call.

Responses
200
The site.Body: PublicSite
404
No site is served on this host.Body: Error

The site’s prices, for its pages

GET/api/public/site/v1/payments
No credentialOn the site's own address

The catalogue for a site’s Buy buttons. No credential: the site comes from the address it’s called on.

Responses
200
The catalogue.Body: object
404
No published site on this address.Body: Error

Start a checkout from a page

POST/api/public/site/v1/payments/checkout
No credentialOn the site's own address

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.

Request body
FieldType and limitsWhat it is
priceRequiredstringpattern ^[a-z0-9][a-z0-9-]{0,62}$
quantityinteger1 to 99
successPathRequiredstringup to 400 characters, pattern ^\/(?![/\\])[^\s\\\u0000-\u001f\u007f]*$
cancelPathRequiredstringup to 400 characters, pattern ^\/(?![/\\])[^\s\\\u0000-\u001f\u007f]*$
emailstringup to 191 characters, email
idempotencyKeystringpattern ^[A-Za-z0-9_-]{16,64}$
gatewaystringpattern ^[a-z][a-z0-9_-]{1,39}$
noTrialboolean
Responses
201
{id, mode, embedded}, or {id, mode, embedded: null, paid: true}.Body: object
404
No such price.Body: Error
409
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.Body: Error
422
A field is not valid, or E_SITE_PAYMENTS_EMAIL_REQUIRED, E_SITE_PAYMENTS_GATEWAY_UNAVAILABLE, E_SITE_PAYMENTS_QUANTITY.Body: Error
502
E_SITE_PAYMENTS_UNAVAILABLE: the same key again is safe.Body: Error

A checkout’s status

GET/api/public/site/v1/payments/checkout/{id}
No credentialOn the site's own address

{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.

ParameterInType and limitsWhat it is
idRequiredpathstringpattern ^pcs_[a-f0-9]{24}$The checkout id (pcs_…).
Responses
200
{status}.Body: object
404
No such checkout on this site.Body: Error

The ways to pay a price, for its pages

GET/api/public/site/v1/payments/gateways
No credentialOn the site's own address

As the Site API’s, for a static site’s Buy button.

ParameterInType and limitsWhat it is
priceRequiredquerystringpattern ^[a-z0-9][a-z0-9-]{0,62}$
Responses
200
{mode, gateways: [{slug, name}]}.Body: object
404
No published site on this address, or no such price.Body: Error

Get the plans an agency sells, on its own website

GET/api/public/site/v1/plans
No credentialOn the site's own addressLimit: 300 a minute per caller.

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.

Responses
200
The catalogue.Body: object
404
Not an agency's own website.Body: Error
Example
{
  "status": 404,
  "errors": [
    {
      "message": "Not found.",
      "code": "E_NOT_FOUND"
    }
  ]
}

Get the site’s public brand

GET/api/public/site/v1/site
No credentialOn the site's own addressLimit: 300 a minute per caller.

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).

Responses
200
The site.Body: 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
No site is served on this host.Body: Error
Example
{
  "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

GET/api/public/store/v1/apps
No credential

Live apps only. q searches names and descriptions (unmatched searches are counted as requests for new apps).

ParameterInType and limitsWhat it is
qquerystringup to 120 charactersSearch words.
categoryquerystringup to 48 characters, pattern ^[a-z0-9-]+$A category slug.
cursorquerystringup to 512 charactersmeta.nextCursor from the previous page.
limitqueryinteger1 to 60Page size.
Responses
200
A page of app cards.Body: object
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
The body or query failed validation.Body: ValidationError

Get a listing

GET/api/public/store/v1/apps/{slug}
No credential

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.

ParameterInType and limitsWhat it is
slugRequiredpathstringup to 96 characters, pattern ^[a-z0-9][a-z0-9-]*$The listing slug, e.g. salon-booking.
Responses
200
The listing.Body: object
404
No live app with that slug.Body: Error
Example
{
  "status": 404,
  "errors": [
    {
      "message": "We couldn't find that app.",
      "code": "APP_NOT_FOUND"
    }
  ]
}

List a listing’s reviews

GET/api/public/store/v1/apps/{slug}/reviews
No credential

Published reviews from verified buyers, with the developer’s reply.

ParameterInType and limitsWhat it is
slugRequiredpathstringup to 96 characters, pattern ^[a-z0-9][a-z0-9-]*$The listing slug, e.g. salon-booking.
sortquery"recent" | "highest" | "lowest"recent (default), highest or lowest.
cursorquerystringup to 512 charactersmeta.nextCursor.
limitqueryinteger1 to 50Page size.
Responses
200
A page of reviews.Body: 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
No live app with that slug.Body: Error
422
The body or query failed validation.Body: ValidationError

List categories

GET/api/public/store/v1/categories
No credential

Store categories with live app counts. Cached 60 seconds with an ETag.

Responses
200
Categories.Body: object[]
Example
{
  "status": 200,
  "data": [
    {
      "slug": "bookings",
      "name": "Bookings",
      "icon": "calendar-check",
      "tone": "bookings",
      "appsCount": 8
    }
  ]
}

List collections

GET/api/public/store/v1/collections
No credential

The store’s collections (a title, a subtitle and the category they show).

Responses
200
Collections.Body: 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"
    }
  ]
}

Plans

Public plan catalogue (the pricing page).

Announcement bar for a page

GET/api/public/banner/v1
No credential

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.

ParameterInType and limitsWhat it is
pathquerystringup to 300 charactersThe page path, e.g. /pricing (default /).
surfacequery"site" | "app"site (default): marketing pages. app: the customer app.
Responses
200
The banner, or null.Body: object
422
The body or query failed validation.Body: ValidationError

List plans

GET/api/public/plans
No credential

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).

ParameterInType and limitsWhat it is
audiencequery"account" | "agency"account (default): customer plans. agency: partner plans.
Responses
200
Plans.Body: object
422
The body or query failed validation.Body: ValidationError

Plan cards and comparison table

GET/api/public/pricing/v1
No credential

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.

ParameterInType and limitsWhat it is
audiencequery"account" | "agency"account (default): customer plans. agency: partner plans.
localequerystringpattern ^[a-z]{2}(-[a-z]{2})?$Text locale, e.g. en (default) or bn; missing text falls back to English.
Responses
200
Cards and table.Body: object
422
The body or query failed validation.Body: ValidationError

Partners

Public partner directory and “Hire an expert” leads.

Get a freelance expert

GET/api/public/experts/{slug}
No credential

One approved, listed expert (card + bio).

ParameterInType and limitsWhat it is
slugRequiredpathstringup to 64 charactersExpert slug.
Responses
200
The expert.Body: object
404
Not a listed expert.Body: Error

Read a sale link offer

GET/api/public/join/{token}
No credential

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).

ParameterInType and limitsWhat it is
tokenRequiredpathstring8 to 32 charactersThe link token (/join/<token>).
Responses
200
The offer.Body: object
402
The agency no longer has SaaS mode.Body: Error
Example
{
  "status": 402,
  "errors": [
    {
      "message": "This feature is not included in your plan.",
      "code": "E_RESOURCE_LIMIT_EXCEEDED"
    }
  ]
}
404
No such link on this host.Body: Error
Example
{
  "status": 404,
  "errors": [
    {
      "message": "This link doesn’t exist.",
      "code": "E_NOT_FOUND"
    }
  ]
}

Ask for an expert

POST/api/public/leads
No credentialLimit: 10 an hour per IP.

“Hire an expert”: matches up to 3 partners. Anonymous or signed in.

Request body
FieldType and limitsWhat 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"
languageRequiredstringpattern ^[a-z]{2}(-[A-Z]{2})?$
countrystringpattern ^[A-Z]{2}$
detailsstringup to 4000 characters
siteIdinteger0 or more
contactNamestring1 to 120 characters
contactEmailstringup to 254 characters, email
partnerobject
partner.typeRequired"agency" | "expert"
partner.slugRequiredstringup to 64 characters
Example
{
  "need": "finish_app",
  "budgetBand": "500_1500",
  "timeline": "month",
  "language": "en"
}
Responses
201
The lead and its matches (with the requester’s token).Body: object
422
The body or query failed validation.Body: ValidationError
429
Rate limited. The body has no code: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: Error

Get your lead

GET/api/public/leads/{publicId}
No credential

The requester’s lead and its matches.

ParameterInType and limitsWhat it is
publicIdRequiredpathstring16 to 16 charactersThe lead’s public id.
tokenquerystringup to 128 charactersThe requester’s token, when not signed in.
Responses
200
The lead.Body: object
404
No such lead for this requester.Body: Error

Contact a matched partner

POST/api/public/leads/{publicId}/contact
No credentialLimit: 30 an hour per IP.

Offers the lead to one of its matched partners.

ParameterInType and limitsWhat it is
publicIdRequiredpathstring16 to 16 charactersThe lead’s public id.
Request body
FieldType and limitsWhat it is
tokenstringup to 128 characters
partnerRequiredstringup to 64 characters
type"agency" | "expert"
Example
{
  "partner": "casa-pixel"
}
Responses
200
Offered.Body: object
404
No such lead or partner.Body: Error
422
The body or query failed validation.Body: ValidationError
429
Rate limited. The body has no code: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: Error

List partner levels

GET/api/public/partner-levels
No credential

Ladder tiers (Starter → Rising → Pro → Elite, Founding): points floor, referral %, perks, and founding spots left.

Responses
200
Levels.Body: object

List listed partners

GET/api/public/partners
No credential

The expert directory.

ParameterInType and limitsWhat it is
countryquerystringpattern ^[A-Z]{2}$
languagequerystringpattern ^[a-z]{2}(-[A-Z]{2})?$
typequery"agency" | "expert"
skillquerystring1 to 40 characters
levelquery"starter" | "rising" | "pro" | "elite" | "founding"
cursorquerystringup to 512 characters
limitqueryinteger1 to 50
Responses
200
A page of partners.Body: object
422
The body or query failed validation.Body: ValidationError

Get a partner

GET/api/public/partners/{slug}
No credential
ParameterInType and limitsWhat it is
slugRequiredpathstringup to 64 charactersPartner slug.
Responses
200
The partner.Body: object
404
Not a listed partner.Body: Error

Record a click on an agency client's referral link

POST/api/public/referral-program/v1/click
No credentialLimit: 120 an hour per IP and agency; floods from one IP stop counting.

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.

Request body

Example
{
  "code": "MAYA-7K2Q",
  "path": "/pricing"
}
Responses
200
Whether the click counted.Body: object
404
Not on an agency's host.Body: Error
422
The body or query failed validation.Body: ValidationError
429
Rate limited. The body has no code: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: Error

Check an agency client's referral code

GET/api/public/referral-program/v1/codes/{code}
No credentialLimit: 60 an hour per user or IP and agency.

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.

ParameterInType and limitsWhat it is
codeRequiredpathstring2 to 32 charactersReferral code (case-insensitive).
Responses
200
The check.Body: object
404
Not on an agency's host, or the agency has no live programme.Body: Error
429
Rate limited. The body has no code: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: Error

Record a referral link click

POST/api/public/referrals/v1/click
No credentialLimit: 120 an hour per IP; floods from one IP stop counting.

A ?ref=CODE landing (landing path + salted IP hash, no other PII).

Request body

Example
{
  "code": "MAYA-7K2Q",
  "path": "/pricing"
}
Responses
200
Whether the click counted.Body: object
422
The body or query failed validation.Body: ValidationError
429
Rate limited. The body has no code: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: Error

Check a referral code

GET/api/public/referrals/v1/codes/{code}
No credentialLimit: 60 an hour per user or IP.

{valid, reason (not_found|inactive|self), referrer, perk}.

ParameterInType and limitsWhat it is
codeRequiredpathstringup to 32 charactersReferral code (case-insensitive).
Responses
200
The check.Body: object
429
Rate limited. The body has no code: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: Error

Referral programme terms

GET/api/public/referrals/v1/program
No credential

Tiers, commission months, the welcome perk and the points rules.

Responses
200
The programme.Body: object

Platform

Other public reads: brand, email assets, app media files, the visitor’s default language.

Get an app media file

GET/api/public/app-media/v1/{path}
No credential

Listing images while EzyStudio stores media itself (with object storage, media URLs point at the bucket). Bundles are never served.

ParameterInType and limitsWhat it is
pathRequiredpathstringThe storage key, e.g. apps/42/screenshot/01jb2x5m3a.png.
Responses
200
The file (PNG, JPEG, WebP or SVG).Body: string (image/*)
404
No such file.Body: Error

Get the host’s brand

GET/api/public/brand
No credential

The agency whose dashboard host this is: EzyStudio itself, or a white-label partner’s brand.

Responses
200
The brand.Body: object
Example
{
  "status": 200,
  "data": {
    "isMaster": true,
    "name": "EzyStudio",
    "brand": null,
    "clientSitePattern": null
  }
}

Get an email image

GET/api/public/email-assets/{file}
No credential

Images used in EzyStudio emails (brand-mark.png).

ParameterInType and limitsWhat it is
fileRequiredpathstringFile name, e.g. brand-mark.png.
Responses
200
The image.Body: string (image/png)
404
Unknown file.

Download your data export

GET/api/public/privacy/v1/exports/{token}
No credentialLimit: 30 an hour per IP.

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.

ParameterInType and limitsWhat it is
tokenRequiredpathstringup to 80 charactersThe token from the email link.
Responses
200
The export.Body: string (application/zip)
404
The link is not valid.Body: Error
410
E_PRIVACY_LINK_EXPIRED: the link has expired.Body: Error
429
Rate limited. The body has no code: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: Error

Download a version’s frozen source (staff link)

GET/api/public/review-source/v1/{token}
No credential

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.

ParameterInType and limitsWhat it is
tokenRequiredpathstringThe signed link token.
Responses
200
The bundle ZIP.Body: string (application/zip)
404
Expired, tampered, or no such source.Body: Error

Download a site database backup

GET/api/public/site-database/v1/backups/{ticket}
No credentialLimit: 30 every 10 minutes per IP.

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.

ParameterInType and limitsWhat it is
ticketRequiredpathstringThe sealed ticket from the download link.
Responses
200
The backup file.Body: string (application/octet-stream)
404
E_SITE_DATABASE_BACKUP_LINK: the link is not valid, was already used, has expired, or the backup is gone.Body: Error
429
Rate limited. The body has no code: {"errors": [{"message": "Too many requests", "retryAfter": 60}]}. Wait the Retry-After seconds, then retry.Body: Error

Signed file link (local development)

GET/api/public/site-files/v1/files/{token}
No credential

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”.

ParameterInType and limitsWhat it is
tokenRequiredpathstringSealed, expiring link token.
Responses
200
The file, with its type and a download or inline disposition.Body: string (application/octet-stream)
403
E_DOWNLOAD_TOKEN: expired or not valid.Body: Error
404
E_FILE_NOT_FOUND.Body: Error

Default language for this visitor

GET/api/public/v1/locale
No credential

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.

Responses
200
The language and the country it came from (null when unknown).Body: object
Example
{
  "locale": "de",
  "country": "DE"
}

Schemas

Envelope

Every JSON success answer: {status, data}, plus message on writes (start/macros/response.ts sendFormatted).

FieldType and limitsWhat it is
statusAlwaysintegerThe HTTP status, repeated.
dataanyThe payload (see each operation).
messagestringWords 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.

FieldType and limitsWhat it is
statusinteger
errorsAlwaysobject[]
errors[].messageAlwaysstring
errors[].codestringMachine code, e.g. E_INVALID_SITE_KEY.
errors[].metaobjectStructured detail for some codes.

ValidationError

422 from a Vine validator: one entry per failed rule (no status key in this shape).

FieldType and limitsWhat it is
errorsAlwaysobject[]
errors[].messageAlwaysstring
errors[].ruleAlwaysstringThe failed rule, e.g. maxLength, required, email.
errors[].fieldAlwaysstringThe field path, e.g. media.0.altText.
errors[].metaobject

CursorMeta

Keyset pages: pass nextCursor back as cursor. There are no totals.

FieldType and limitsWhat it is
nextCursorAlwaysstring | null
hasNextPageAlwaysboolean

PublicSite

The public projection of a site (site_public.controller.ts publicSiteProjection). Never the account, the owner or a setting.

FieldType and limitsWhat it is
idAlwaysinteger
nameAlwaysstring
slugAlwaysstring
kindAlways"static" | "app"static = website, app = logins, payments, database.
templateAlwaysstring | nullastro-app or astro-static.
urlAlwaysstring | nullThe site’s primary address (custom domain, else <slug>.ezystudio.app).
logoAlwaysstring | null
favIconAlwaysstring | null
brandColorAlwaysstring | null

AppManifest

FieldType and limitsWhat it is
namestring1 to 80 charactersThe app’s name on the store.
categorySlugstringup to 48 charactersOne of GET /api/public/store/v1/categories (bookings, food, selling, learning, events, listings, business, websites).
taglinestring | nullup to 60 charactersOne line under the name. Required to submit.
descriptionstring | nullup to 5000 charactersWhat the app does, in plain words. At least 40 characters to submit.
dataNotestring | nullup to 2000 charactersWhat the app stores about the owner’s customers and why. Shown under “Your data”. At least 20 characters to submit.
supportEmailstring | nullup to 254 characters, emailWhere buyers reach you. Falls back to your developer profile’s support email.
languagesstring[] | nullup to 30 itemsLanguage codes the app works in, e.g. en, es.
plans"website" | "app" | "business"[]up to 3 itemsEzyStudio 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).
priceCentsinteger | null0 to 99900Price 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.
customizationobjectWhat a buyer’s one-sentence customization may change. Defaults: colors, text and fields on, layout off.
customization.colorsbooleanColors and fonts.
customization.textbooleanNames, labels and copy.
customization.fieldsbooleanForm fields.
customization.layoutbooleanLayout and order of sections.
customization.samplestringup to 500 charactersAn example sentence for buyers.
includedany[]up to 50 itemsListing bullets: what the app includes (strings or objects, up to 50).
featuresany[]up to 50 itemsFeature cards on the listing, e.g. {icon, group, title, description} (up to 50).
setupStepsany[]up to 50 itemsSteps shown while the app installs. Strings or {title} / {label} objects; the first 6 are used.
madeForany[]up to 50 itemsKinds of businesses it is for (up to 50).
trySayingany[]up to 50 itemsExample customization sentences (up to 50).
changelogstring | nullup to 2000 charactersWhat’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.
customerImpactstring | nullup to 2000 charactersWhat 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.
customerImpactNonebooleanTrue when nothing is removed or changed in a way customers lose (instead of customerImpact).
aiUpdateNotesstring | nullup to 8000 charactersUpdate 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.
versionstringpattern ^\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.
repoobject | nullWhere 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.installationIdAlwaysinteger0 to 9007199254740991GitHub’s installation id, from the GitHub connection’s installations.
repo.repositoryIdAlwaysinteger0 to 9007199254740991GitHub’s repository id (one the installation covers).
repo.branchAlwaysstring1 to 255 charactersThe branch to take the code from.
repo.recheckbooleanCheck the branch’s latest commit again even when the pick is unchanged.
mediaobject[]up to 10 itemsReplaces the whole list. Up to 1 icon, 1 cover and 8 screenshots; order = position within each kind.
media[].kindAlways"icon" | "cover" | "screenshot"icon, cover or screenshot.
media[].keystringup to 512 charactersThe key from the presign answer (preferred).
media[].urlstringup to 1024 characters, uriOnly a URL this API returned for one of this app’s files. New outside URLs are refused.
media[].altTextstringup to 250 charactersRequired on every image to submit.
media[].titlestringup to 120 charactersOptional caption.
media[].widthinteger1 to 20000Pixels.
media[].heightinteger1 to 20000Pixels.
Generated from the API

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).