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.
| Surface | Prefix | Credential | Called from |
|---|---|---|---|
| Site API | /api/site/v1 | Site Key | The site's server only |
| Public site | /api/public/site/v1 | None | Anywhere (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 isstk_, 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
/apiwithout 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.
| Code | Status | When |
|---|---|---|
E_NO_SITE_KEY | 401 | No Authorization header. |
E_BAD_SITE_KEY | 401 | The header is not a Site Key. |
E_INVALID_SITE_KEY | 401 | Unknown 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_REVOKED | 401 | The key was revoked. It works again once it is rotated. |
E_TOO_MANY_REQUESTS | 429 | Too 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. |
{"errors": [{"message": "Too many requests", "retryAfter": 60, "code": "E_TOO_MANY_REQUESTS"}]}Send an account email
/api/site/v1/emailsAsks 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.
| kind | Sends | Needs |
|---|---|---|
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 |
notification | Your own plain-text message, to the account's owner. | subject, text. Leave out to: it goes to the account's owner |
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. |
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}{"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.
| Answer | Code | Meaning |
|---|---|---|
| 202 | Accepted. | |
| 401 | E_…_SITE_KEY | See Authentication above. |
| 409 | E_SITE_HAS_NO_HOST | You sent a path, and the site has no address to build the link on yet. |
| 422 | No code | The body failed validation (one entry per field). |
| 422 | E_SITE_EMAIL_RECIPIENT | A notification to an address that isn't one of the account's owners. |
| 429 | E_TOO_MANY_REQUESTS | The site's hourly email limit for this kind of key, or its Site API call limit. |
| 429 | E_SITE_EMAIL_LIMIT | The site's daily email limit (all emails, or notifications). Try again later. |
Limits
| Limit | Value |
|---|---|
| Site API calls, per site and kind of key | 600 a minute |
| Emails per site | 120 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 site | 100 in any 24 hours |
| Emails to one address | 5 an hour from one site, 15 an hour across all sites |
| Email checks, per site and kind of key | 300 an hour |
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
/api/site/v1/email-checkAsks 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.
{"status": 200, "data": {"accept": true}}Other Site API calls
POST /api/site/v1/jobsand/jobs/cancel: background jobs. See Background jobs.POST /api/site/v1/files/uploadsand the other/filescalls: 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.
/api/public/site/v1/site/api/public/site/v1/initial-dataThe 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.
| 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 |
{"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
/api/public/site/v1/agent-docs/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-appor?template=astro-static, else the template of the site on this address, elseastro-app. .mdon the topic is optional;coreis the core doc.- Cached 5 minutes with an ETag: send
If-None-Matchto get a 304. TheX-Agent-Docs-Templateheader names the template. - Unknown topic:
404 E_AGENT_DOC_NOT_FOUND. If the docs can't be rendered:503 E_AGENT_DOCS_UNAVAILABLE.
| Template | Topics |
|---|---|
astro-app | auth, background-jobs, database, performance, publishing, roles-and-permissions, security, seo, site-api |
astro-static | accessibility, performance, publishing, security, seo |
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.