Skip to content
Developer docs

Site API

The calls a site makes to EzyStudio. A site's server sends account emails, checks sign-up addresses and sends background jobs through the Site API with its Site Key; anything can read the site's public brand and the platform rules for coding agents.

SurfacePrefixCredentialCalled from
Site API/api/site/v1Site KeyThe site's server only
Public site/api/public/site/v1NoneAnywhere (read only)

Where to call

Call the site's own address: EzyStudio answers /api/* on every site host (https://<slug>.ezystudio.app or the site's custom domain). A site reads it from SITE_URL. The public site endpoints work out which site you mean from that address.

A deployed site's server can reach only the EzyStudio platform host and its own database. Every other outbound call is blocked, which is why emails go through EzyStudio.

Authentication: the Site Key

  • Every app site (a site with a server and a database) has a Site Key. EzyStudio sets it on the deployed site as the secret SITE_KEY; the editor's preview gets its own key. You never create or copy it by hand.
  • Send it as Authorization: Bearer stk_…. The format is stk_, a 16-character public id, _, then a 43-character secret (letters and digits).
  • Server to server only. A browser must never hold the key, so never send it from page code: a page on the site's own address reaches /api without any CORS check. Read it only in server code (in the templates, src/platform/config.ts).
  • On a site's own address, the key must be that site's key.
  • Someone allowed to manage the site's secrets can rotate or revoke a key. A rotated key stops working on the next request; the live site gets the new one when it is published again.
  • Static websites have no Site Key and no Site API.
CodeStatusWhen
E_NO_SITE_KEY401No Authorization header.
E_BAD_SITE_KEY401The header is not a Site Key.
E_INVALID_SITE_KEY401Unknown key, wrong secret, a deleted site, an inactive account, or another site's key on this site's address. Always the same answer.
E_SITE_KEY_REVOKED401The key was revoked. It works again once it is rotated.
E_TOO_MANY_REQUESTS429Too many failed keys from one IP address (120 a minute, then a minute's wait), or a rate limit below. The body is below, with a Retry-After header in seconds. Send Accept: application/json, as the templates do: without it the body is plain text.
429 Too Many Requests
{"errors": [{"message": "Too many requests", "retryAfter": 60, "code": "E_TOO_MANY_REQUESTS"}]}

Send an account email

POST/api/site/v1/emails

Asks EzyStudio to send one of the site's account emails. You send no HTML, sender or full URL, and no subject except for a notification: EzyStudio renders each kind with the site's name and brand, and builds the link on the site's own address from the path you send, so a forged Host header can never point a reset link somewhere else.

kindSendsNeeds
password_reset“Reset your {site} password”, with a button to the link.path, optional expiresInMinutes
verify_email“Confirm your email for {site}”.path, optional expiresInMinutes
password_changed“Your {site} password was changed”.Optional path
account_exists“You already have a {site} account” (sent instead of a second sign-up).Optional path
confirm_email_change“Confirm your new email for {site}”, sent to the new address.path, optional expiresInMinutes
email_changed“Your {site} email was changed”.Optional path
sign_in_code“{site} sign-in code”: a one-time code, never a link.code: exactly 6 digits
notificationYour own plain-text message, to the account's owner.subject, text. Leave out to: it goes to the account's owner

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.
Request
POST https://luma-hair-x7k2p.ezystudio.app/api/site/v1/emails
Authorization: Bearer stk_…
Content-Type: application/json

{"kind": "password_reset", "to": "maya@lumahair.test",
 "path": "/account/reset?token=8f2c…", "expiresInMinutes": 30}
202 Accepted
{"status": 202, "message": "Email accepted for delivery.", "data": {"accepted": true}}

The answer is the same whether the email went out or was dropped because that address got too many emails: nobody can use it to find out who has an account.

AnswerCodeMeaning
202Accepted.
401E_…_SITE_KEYSee Authentication above.
409E_SITE_HAS_NO_HOSTYou sent a path, and the site has no address to build the link on yet.
422No codeThe body failed validation (one entry per field).
422E_SITE_EMAIL_RECIPIENTA notification to an address that isn't one of the account's owners.
429E_TOO_MANY_REQUESTSThe site's hourly email limit for this kind of key, or its Site API call limit.
429E_SITE_EMAIL_LIMITThe site's daily email limit (all emails, or notifications). Try again later.

Limits

LimitValue
Site API calls, per site and kind of key600 a minute
Emails per site120 an hour for each kind of key (the live site's and the editor preview's count apart), 1,000 in any 24 hours
Notification emails per site100 in any 24 hours
Emails to one address5 an hour from one site, 15 an hour across all sites
Email checks, per site and kind of key300 an hour
In the templates

EzyStudio's app template already does this: sendSiteEmail() in src/platform/auth/email.ts sends the auth emails, and every platform call goes through src/platform/api.ts (fixed origin, a timeout, a User-Agent). Use those rather than calling fetch() yourself.

Check a sign-up address

POST/api/site/v1/email-check

Asks whether an address looks like a real inbox, before the site creates an account or changes a user's email. Send {"email": "…"}; the answer is only yes or no, never the reason. An address that isn't an email at all fails validation (422). The template calls it from src/platform/auth/email-check.ts.

200 OK
{"status": 200, "data": {"accept": true}}

Other Site API calls

  • POST /api/site/v1/jobs and /jobs/cancel: background jobs. See Background jobs.
  • POST /api/site/v1/files/uploads and the other /files calls: uploads, signed links and deletes. See File storage.
  • POST /api/site/v1/owner-sign-in: the site's owner opening the admin with View as admin spends its ticket here. You don't call it yourself.

Public site endpoints

No credential. Read only, cached, and limited to 300 requests a minute per caller.

GET/api/public/site/v1/site
GET/api/public/site/v1/initial-data

The public brand of the site on this address. Both paths answer the same; the templates call /initial-data. Cached for 60 seconds. It never includes the account, the owner or any setting. On an address with no site: 404 E_SITE_NOT_FOUND.

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
200 OK
{"status": 200, "data": {"id": 4, "name": "Luma Hair", "slug": "luma-hair-x7k2p", "kind": "app",
  "template": "astro-app", "url": "https://luma-hair-x7k2p.ezystudio.app",
  "logo": null, "favIcon": null, "brandColor": null}}

Rules for coding agents

GET/api/public/site/v1/agent-docs
GET/api/public/site/v1/agent-docs/{topic}

Markdown, not JSON: the platform rules every AI agent follows when it works on an EzyStudio site (Claude Code, Codex, Gemini, Cursor). The core doc lists the topics; read the one that fits before that kind of work. Inside the EzyStudio editor the same docs are already on disk.

  • Which template: ?template=astro-app or ?template=astro-static, else the template of the site on this address, else astro-app.
  • .md on the topic is optional; core is the core doc.
  • Cached 5 minutes with an ETag: send If-None-Match to get a 304. The X-Agent-Docs-Template header names the template.
  • Unknown topic: 404 E_AGENT_DOC_NOT_FOUND. If the docs can't be rendered: 503 E_AGENT_DOCS_UNAVAILABLE.
TemplateTopics
astro-appauth, background-jobs, database, performance, publishing, roles-and-permissions, security, seo, site-api
astro-staticaccessibility, performance, publishing, security, seo
Paths that belong to EzyStudio

Never put a page or a route of your own under /api: on a site's address it belongs to EzyStudio. Your site's own backend lives at /site-api, and account pages under /account/*.

Every endpoint with full request and response schemas is in the API reference.