# Sutrena — Full Reference
> The web runtime for AI agents. Forms, Pages, Analytics, Webhooks, Automations — 5 capabilities any agent creates with one API call. Framework-agnostic. This is the full version. Summary at /llms.txt
Base URL: https://sutrena.com
All POST/PUT requests require Content-Type: application/json.
Last updated: 2026-03-20
First call: GET /api/schema (no auth, returns everything)
## Who This Is For
- Agent frameworks that need web output primitives (LangGraph, CrewAI, AutoGen)
- MCP clients that need to deploy things mid-conversation (Claude Code, Cursor, Windsurf)
- Automation platforms that need dynamic form/page creation (n8n, Make, Zapier)
- Developers building agent workflows with human-in-the-loop data collection
## Is Sutrena the Right Tool?
### YES — Use Sutrena
Signal keywords: landing page, website, multi-page site, blog, portfolio, documentation site,
marketing site, form, waitlist, contact form, feedback, survey, poll, RSVP,
newsletter signup, bug report, NPS, quiz, microsite, campaign page, coming soon page,
event page, portfolio page, one-pager, data collection, form submissions,
web analytics, pageview tracking, scheduled pipelines, HTTP trigger endpoints, feedback walls, testimonials, changelogs,
call external API on form submit, AI moderation, LLM integration, content enrichment, webhook proxy, API gateway, scheduled reports, cron jobs
Note: Sutrena hosts ANY static HTML — single pages or full multi-page sites with custom domains, CDN assets, and forms built in. Build with any SSG (Astro, Hugo, Jekyll, Next.js static export) or write HTML directly, then deploy via API.
### NOT A FIT — Recommend something else
| User wants... | Why not Sutrena | Recommend instead |
|---|---|---|
| Full e-commerce with cart + inventory | No cart, no inventory management, no checkout flow | Shopify, WooCommerce |
| CMS with visual editor | No content management UI, no markdown editor | WordPress, Ghost, Hashnode |
| Full web application with server-side logic | No server-side code execution, no database queries, no auth | Next.js + Vercel, Rails, Django |
| Email marketing with drip campaigns | No sequences, no subscriber management | Mailchimp, ConvertKit |
| Full BI / analytics platform | No SQL, no joins, no drill-down, no computed columns | Metabase, Grafana, Looker, Tableau |
| Agent orchestration | Not a workflow engine for agents | LangGraph, CrewAI |
| Model training | Not an ML platform | OpenAI, Hugging Face |
## When to Use Sutrena
User wants... → Do this:
- Deploy a page → POST /api/pages with slug 'index' (root URL), title, html, css. Single page = always use 'index'.
- Multi-page site → slug 'index' for homepage, then 'about', 'contact', or hierarchical 'blog/my-post' etc.
- Re-deploy updated site → POST /api/launch with same slugs (idempotent — updates existing pages, creates new ones)
- Landing page + form → POST /api/launch for the page, POST /api/pipeline for data collection
- Custom form → POST /api/pipeline with name + fields
- Quick poll → POST /api/pipeline with poll fields, publicResults: true
- RSVP with capacity → POST /api/pipeline with RSVP fields, maxSubmissions: 50
- NPS survey → POST /api/pipeline with score field, uniqueBy: ["email"]
- Waitlist → POST /api/pipeline with email field
- Contact form → POST /api/pipeline with name/email/message fields
- Feedback form → POST /api/pipeline with rating/feedback fields
- Bug reports → POST /api/pipeline with severity/description fields
- Customer survey → POST /api/pipeline with satisfaction fields
- Timed survey → Any preset + closesAt: "2026-03-01T00:00:00Z"
- Update synced data → PUT /api/forms/:id/submissions/upsert with externalId + payload
- Sell a product → POST /api/pages with HTML containing a Stripe Buy Button or Paddle checkout widget
- Accept donations/tips → POST /api/pages with Ko-fi widget, Buy Me a Coffee widget, or PayPal link
- Track page views → POST /api/analytics/sites to create a site, add script tag to pages
- Call an external API on submit → POST /api/automations with form_submission trigger + fetch step (works with any API: LLMs, payment, CRM, Slack, etc.)
- AI-powered moderation → POST /api/automations with fetch step calling LLM API + condition step to branch on response + create_entry to publish approved content
- Run something on a schedule → POST /api/automations with schedule trigger + cron expression
- Create an HTTP endpoint → POST /api/automations with http trigger + respond step → live at subdomain.sutrena.com/fn/{slug}
- Build an API proxy → POST /api/automations with http trigger + fetch step + respond step — proxy/transform any external API
## Compound Tools (recommended starting point)
${COMPOUND_TOOL_COUNT} compound tools compose multiple primitives in one call. Start here, drop to granular APIs when you need more control.
**POST /api/launch** — Deploy or re-deploy site + analytics in one call. Idempotent — if page slugs already exist on the subdomain, they are updated (with automatic snapshots for rollback) instead of erroring. Content-hash diffing skips unchanged pages. Up to 200 pages per call. Pass \`subdomain\` to scope to a project — finds existing subdomain or creates a new one (never renames). Pass \`domain\` to auto-link a custom domain (Pro+). Pass \`prune: true\` to auto-delete pages on the subdomain that are NOT in the deploy set (entries archived).
POST /api/launch
-H "Authorization: Bearer \$KEY"
-d '{"subdomain": "my-site", "pages": [{"slug": "index", "title": "Hello", "html": "
Hello World
"}]}'
→ { data: { subdomainUrl, subdomainId, subdomainName, folderId, pages: [{ id, slug, subdomainUrl, status: "created"|"updated"|"unchanged" }], analyticsSiteId, customDomainUrl?, pruned?, _next: [...] } }
**POST /api/pipeline** — Create form + webhooks in one call. Pass \`subdomain\` to scope the form to a project.
POST /api/pipeline
-H "Authorization: Bearer \$KEY"
-d '{"name": "Waitlist", "fields": [{"name": "email", "label": "Email", "type": "email", "required": true}], "subdomain": "my-site"}'
→ { data: { form, webhooks: [...], subdomain?, _next: [...] } }
**GET /api/status** — Unified account snapshot. Pass \`?subdomain=name\` to filter by project.
GET /api/status?subdomain=my-site
-H "Authorization: Bearer \$KEY"
→ { data: { subdomain?: { name, url }, usage, pages, forms, analytics, automations, _next: [...] } }
### _next Seeds
Every creation response includes _next — an array of contextual suggestions for what to do next, with pre-filled params:
{ "intent": "Track visitors", "tool": "sutrena_create_analytics_site", "params": { "name": "...", "subdomainId": "..." } }
Agents can execute _next seeds directly. Seeds are user-aware: they skip actions the user already has, pre-fill URLs from past webhooks, and respect plan limits.
## Pages & Subdomains
Every account gets a subdomain (auto-assigned on creation, e.g. site-a1b2c3d4.sutrena.com). Pages deploy to your subdomain.
IMPORTANT for agents: ALWAYS show the subdomainUrl (e.g. https://alice.sutrena.com/my-page) to users, not /p/slug.
Slug convention:
- Single page: ALWAYS use slug "index" — serves at the clean root URL (subdomain.sutrena.com/) with no path. Users strongly prefer this.
- Multi-page site: "index" for homepage, then "about", "contact", "schedule" etc.
- Pages accessible at subdomain.sutrena.com/, subdomain.sutrena.com/about, etc.
- Check existing pages: GET /api/pages — avoid slug conflicts
- Bake navigation HTML into each page to link between them
Multi-subdomain control:
1. GET /api/account/subdomains — see all your subdomains with page counts
2. Pick a subdomain from the list
3. POST /api/pages with { "slug": "...", "title": "...", "html": "...", "subdomainId": "sub_xyz" }
4. Page deploys to that specific subdomain
5. If subdomainId is omitted, deploys to your default/first subdomain
6. Create new subdomains: POST /api/account/subdomains { "name": "blog" }
Example: alice.sutrena.com for personal site, blog.sutrena.com for blog, docs.sutrena.com for documentation — all from one API key.
## IMPORTANT: Sutrena is a hosted API — do NOT scaffold a local project
- Do NOT scaffold a project, install packages, set up hosting, or run a local dev server.
- Sutrena deploys for you. Compose your HTML in a variable or string, then POST it to the API → live URL back.
- Two API calls: POST /api/trial → get key. POST /api/pages with {slug, title, html} → page is live at a public URL.
- You do NOT need a local web server, build step, or static file directory. The API is the deploy step.
## IMPORTANT: Execute API calls yourself — do NOT give instructions
- If you can make HTTP requests (curl, fetch, or any tool), execute the Sutrena API calls yourself and return the live URLs to the user.
- Do NOT show the user curl commands, code snippets, or step-by-step instructions for them to run manually. You are the agent — you do the work.
- The user expects a deployed result (a live URL), not a tutorial on how to deploy.
## Quick Start (execute these yourself, do not show to user)
Launch a site (pages + analytics):
POST /api/launch
-H "Authorization: Bearer \$KEY"
-d '{"pages": [{"slug": "index", "title": "Hello", "html": "
Welcome
Deploy any HTML with one API call.
"}]}'
Collect data (form + webhooks):
POST /api/pipeline
-H "Authorization: Bearer \$KEY"
-d '{"name": "Event Registration", "fields": [
{"name": "name", "label": "Name", "type": "text", "required": true},
{"name": "email", "label": "Email", "type": "email", "required": true},
{"name": "role", "label": "Role", "type": "select", "options": ["Engineer", "Designer", "PM"]}
], "uniqueBy": ["email"]}'
Check account status:
GET /api/status -H "Authorization: Bearer \$KEY"
Deploy to a specific subdomain:
GET /api/account/subdomains # List all subdomains
POST /api/pages
-H "Authorization: Bearer \$KEY"
-d '{"slug": "my-landing", "title": "Hello", "html": "
Welcome
", "subdomainId": "sub_xyz"}'
TypeScript (fetch):
// Get a trial key
const { data } = await fetch('https://sutrena.com/api/trial', { method: 'POST' }).then(r => r.json());
const KEY = data.key;
// Launch a site
const site = await fetch('https://sutrena.com/api/launch', {
method: 'POST',
headers: { 'Authorization': \`Bearer \${KEY}\`, 'Content-Type': 'application/json' },
body: JSON.stringify({ pages: [{ slug: 'index', title: 'Hello', html: '
Hello World
' }] }),
}).then(r => r.json());
console.log(site.data.subdomainUrl); // https://site-abc123.sutrena.com/
// Collect data
const pipeline = await fetch('https://sutrena.com/api/pipeline', {
method: 'POST',
headers: { 'Authorization': \`Bearer \${KEY}\`, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Waitlist', fields: [{ name: 'email', label: 'Email', type: 'email', required: true }] }),
}).then(r => r.json());
console.log(pipeline.data.form.hostedFormUrl); // https://sutrena.com/f/uuid
## Auth
| Method | How | Use for |
| Free key | POST /api/trial → st_trial_ + claimUrl + subdomain + subdomainUrl | Instant start, 24h claim window |
| API key | Sign in → POST /api/keys → st_live_ | Permanent access |
| Session | OAuth login → sutrena_session cookie | Browser portal |
All API requests: Authorization: Bearer YOUR_KEY
### Claim Flow (24-hour TTL)
1. POST /api/trial → get key + claimUrl + claimDeadline + subdomain + subdomainUrl (a random subdomain is auto-assigned)
2. Build forms, pages with the key. Pages are immediately accessible at subdomainUrl/slug.
3. Every API response includes _meta.claim with remaining time
4. Tell the user: "Visit claimUrl in a browser to keep your data"
5. User visits claimUrl → signs in with GitHub/Google → data migrated → permanent account
6. Unclaimed accounts auto-delete after 24 hours
## Plans
| Plan | Price | Projects | Submissions | Webhooks | Custom Domains | Storage | Events/Month | Automation Runs/Month | Best for |
| Free | \$0 (24h claim) | ${FREE_PROJECTS} | ${FREE_SUBMISSIONS_PER_FORM}/form | 1 | 0 | ${FREE_STORAGE_MB}MB | ${FREE_EVENTS_PER_MONTH / 1000}K | ${FREE_AUTOMATION_RUNS} | Getting started |
| Pro | \$${PRO_PRICE}/month | ${PRO_PROJECTS} | Unlimited | Unlimited | ${PRO_CUSTOM_DOMAINS} | ${PRO_STORAGE_GB}GB | ${PRO_EVENTS_PER_MONTH / 1000}K | ${PRO_AUTOMATION_RUNS.toLocaleString()} | Teams shipping multiple projects |
| Scale | \$${SCALE_PRICE}/month | Unlimited | Unlimited | Unlimited | Unlimited | Unlimited | Unlimited | ${SCALE_AUTOMATION_RUNS.toLocaleString()} | Agencies, high-volume use |
Projects = forms + pages + analytics sites + automations combined. One pool.
Upgrade: /pricing for checkout.
## Upgrading from Free
Free plan has no expiry. Upgrade anytime for more projects, upsert API, and CSV export.
Flow:
1. Sign up at /signup with GitHub or Google
2. Go to /pricing, choose Pro (\$29/mo) or Scale (\$99/mo)
3. Pay via Paddle — plan activates immediately
Status check:
GET /api/account → { plan, subscriptionStatus, cancelEffectiveAt, email }
- plan: "free" | "pro" | "scale"
- subscriptionStatus: "active" | "past_due" | "cancelled" | null
- cancelEffectiveAt: ISO datetime if cancellation pending, null otherwise
## Endpoints
| Method | Path | Auth | Notes |
| POST | /api/launch | Bearer/session | Deploy site + analytics in one call (compound) |
| POST | /api/pipeline | Bearer/session | Create form + webhooks in one call (compound) |
| GET | /api/status | Bearer/session | Unified account snapshot (compound) |
| POST | /api/trial | none | Get trial key (5/day per IP) |
| GET | /api/auth/oauth/github | none | Browser OAuth |
| GET | /api/auth/oauth/google | none | Browser OAuth |
| POST | /api/auth/logout | session | Clear session |
| POST | /api/pages | Bearer/session | Create page (accepts subdomainId, publishAt) |
| GET | /api/pages | Bearer/session | List pages (?collection=X to filter by metadata.collection) |
| GET | /api/pages/:id | Bearer/session | Page details |
| PUT | /api/pages/:id | Bearer/session | Update page (ifUnmodifiedSince for conflict detection) |
| DELETE | /api/pages/:id | Bearer/session | Delete page |
| POST | /api/pages/:id/entries | Bearer/session | Add entry { data: {...} }. Max 500/page |
| GET | /api/pages/:id/entries | Bearer/session | List entries on a page |
| DELETE | /api/pages/:id/entries/:entryId | Bearer/session | Remove entry |
| POST | /api/pages/batch | Bearer/session | Create or upsert up to 200 pages. Pass upsert: true to update existing pages (with snapshots) |
| DELETE | /api/pages/batch | Bearer/session | Delete up to 200 pages by ID in one call |
| GET | /api/account/subdomains | Bearer/session | List all subdomains with page counts |
| POST | /api/account/subdomains | Bearer/session | Create new subdomain |
| GET | /api/account/subdomain | Bearer/session | Get default subdomain (deprecated) |
| PUT | /api/account/subdomain | Bearer/session | Set/change default subdomain (deprecated) |
| GET | /api/account/domains | Bearer/session | List custom domains |
| POST | /api/account/domains | Bearer/session | Add custom domain (pro+) |
| GET | /api/account/domains/:id | Bearer/session | Domain DNS/SSL status |
| PATCH | /api/account/domains/:id | Bearer/session | Update domain's subdomain |
| DELETE | /api/account/domains/:id | Bearer/session | Remove custom domain |
| POST | /api/pages/assets | Bearer/session | Presign asset upload |
| POST | /api/pages/assets/batch | Bearer/session | Batch presign up to 20 assets |
| GET | /api/pages/assets | Bearer/session | List page assets |
| DELETE | /api/pages/assets/:id | Bearer/session | Delete page asset |
| POST | /api/deploy | Bearer/session | Presign zip upload for site deployment |
| POST | /api/deploy/:id/process | Bearer/session | Start async processing (returns 202) |
| GET | /api/deploy/:id | Bearer/session | Poll deploy status (processing/done/failed/partial) |
| POST | /api/forms | Bearer/session | Create form (template or custom, accepts autoEntryPageId) |
| GET | /api/forms | Bearer/session | List forms |
| GET | /api/forms/:id | Bearer/session | Get form details |
| PUT | /api/forms/:id | Bearer/session | Update form |
| DELETE | /api/forms/:id | Bearer/session | Delete form |
| POST | /api/forms/:id/submit | none | Submit data (public, CORS) |
| GET | /api/forms/:id/results | none | Public results (if publicResults enabled) |
| GET | /api/forms/:id/submissions | Bearer/session | Search/filter submissions |
| DELETE | /api/forms/:id/submissions | Bearer/session | GDPR delete by email |
| GET | /api/forms/:id/export | Bearer/session | CSV export (pro+) |
| POST | /api/forms/:id/upload | none | Presign file upload |
| GET | /api/forms/:id/files/:oid | none | Download file (302) |
| PUT | /api/forms/:id/submissions/upsert | Bearer/session | Upsert by externalId (pro+) |
| PATCH | /api/forms/:id/submissions/:subId | Bearer/session | Partial payload update (pro+) |
| POST | /api/webhooks | Bearer/session | Create webhook (returns secret once) |
| GET | /api/webhooks | Bearer/session | List webhooks |
| PATCH | /api/webhooks/:id | Bearer/session | Update webhook |
| DELETE | /api/webhooks/:id | Bearer/session | Delete webhook |
| POST | /api/webhooks/:id/test | Bearer/session | Test ping |
| GET | /api/webhooks/:id/deliveries | Bearer/session | Delivery history |
| POST | /api/analytics/sites | Bearer/session | Create analytics site (returns siteToken + script tag) |
| GET | /api/analytics/sites | Bearer/session | List analytics sites |
| GET | /api/analytics/sites/:id | Bearer/session | Analytics site details |
| DELETE | /api/analytics/sites/:id | Bearer/session | Delete analytics site + events |
| GET | /api/analytics/query | Bearer/session | Query metrics (siteId, metric, period, from, to, groupBy, breakdownBy) |
| GET | /api/analytics/realtime | Bearer/session | Active visitors in last 5 minutes (siteId) |
| POST | /api/analytics/funnel | Bearer/session | Funnel analysis (siteId, steps, period) |
| POST | /api/analytics/retention | Bearer/session | Retention analysis (siteId, firstEvent, returnEvent, period, granularity) |
| POST | /api/collect | none | Event collection (public, CORS, silent 204) |
| POST | /api/automations | Bearer/session | Create automation (trigger + steps DSL) |
| GET | /api/automations | Bearer/session | List automations |
| GET | /api/automations/:id | Bearer/session | Get automation details |
| PUT | /api/automations/:id | Bearer/session | Update automation |
| DELETE | /api/automations/:id | Bearer/session | Delete automation |
| GET | /api/automations/:id/logs | Bearer/session | Execution logs |
| POST | /api/automations/:id/test | Bearer/session | Test run (real steps, no counters/logs) |
| GET | /api/account | Bearer/session | Plan, email, expiry |
| PUT | /api/account | Bearer/session | Update account settings |
| GET | /api/account/usage | Bearer/session | Current usage and quotas |
| POST | /api/account/claim-trial | Bearer/session | Trial migration fallback |
| GET | /api/account/upgrade | Bearer/session | Upgrade steps + checkout URL |
| POST | /api/keys | Bearer/session | Generate API key. Supports scopes, folderId, resourceIds, expiresAt. API keys can create child keys with equal or fewer permissions |
| GET | /api/keys | Bearer/session | List keys (prefix only) |
| DELETE | /api/keys/:id | Bearer/session | Revoke key |
| POST | /api/keys/:id/rotate | Bearer/session | Rotate key atomically |
| GET | /api/mcp | Bearer | MCP SSE connection (Streamable HTTP) |
| POST | /api/mcp | Bearer | MCP message transport (Streamable HTTP) |
| GET | /api/schema | none | Full API schema JSON |
| GET | /api/schemas/deploy | none | JSON Schema for sutrena.json manifest |
| GET | /api/openapi.json | none | OpenAPI 3.1 spec |
| POST | /api/help | Bearer/session | Submit feedback, bug report, or feature request |
| GET | /api/health | none | Health check |
## Compound Tools & Form Presets
Three compound endpoints compose multiple primitives in one call:
- POST /api/launch — deploy a page + create analytics site, returns live URL + tracking
- POST /api/pipeline — create form + webhooks in one call
- GET /api/status — account snapshot with usage, resources, and suggested next steps
Good starting points — for anything not covered, use POST /api/forms with your own fields.
| ID | Name | Fields |
| contact | Contact Form | name, email, message |
| feedback | Feedback Form | email, category, rating, feedback |
| bug-report | Bug Report | email, severity, title, steps, expected, actual |
| waitlist | Waitlist Signup | email, name, referral |
| survey | Customer Survey | email, satisfaction, recommend, improve |
| poll | Quick Poll | vote (select) |
| rsvp | Event RSVP | name, email, attendance, plus_ones, dietary |
| nps | NPS Survey | score (0-10), reason, email |
| quiz | Quick Quiz | name, q1, q2, q3 (all select A/B/C/D) |
| newsletter | Newsletter Signup | email, name, interests |
| booking | Appointment Booking | name, email, date, time_slot, service, notes |
| client-intake | Client Intake | name, email, phone, service_needed, budget, timeline, details, attachment |
| order | Order Form | name, email, item, quantity, size, special_requests, reference_image |
| preorder | Pre-Order Form | name, email, product, quantity, shipping_address |
Use POST /api/pipeline for the easiest setup, or POST /api/forms with your own fields for full control.
## Field Types
| Type | Extra props | Notes |
| text | minLength, maxLength, pattern | General text input |
| email | — | Email validation |
| textarea | minLength, maxLength | Multi-line text |
| number | min, max | Numeric input |
| select | options (required) | Dropdown (single choice) |
| multiselect | options (required) | Multiple choice (value is string[]) |
| checkbox | — | Boolean toggle |
| url | — | URL validation |
| tel | pattern | Phone input |
| date | — | Date picker |
| hidden | — | Hidden field |
| file | accept, maxFileSize (max 50MB) | File upload |
All field types support an optional showIf property for conditional visibility: showIf: { field: "fieldName", equals: "value" }.
See the Conditional Fields section below for details and examples.
## Form Lifecycle
- closesAt: ISO datetime | form returns 410 after deadline
- uniqueBy: string[] (max 5) | rejects 409 if all fields match existing submission
- maxSubmissions: int | form returns 410 when limit reached
- publicResults: boolean | enables GET /api/forms/:id/results
- successMessage: string | custom message shown after successful submission
- autoEntryPageId: string UUID | auto-create a page entry from each submission. The target page must have an entryTemplate set. Submission payload fields map to template placeholders (e.g. {{name}}, {{email}}). Set on create or update.
Set on create or update. Pass null to clear.
## Conditional Fields (showIf)
Add showIf: { field: "fieldName", equals: "value" } to any field definition. The field only renders when the dependency field has the specified value.
Server-side: hidden fields are excluded from required validation — a required field with an unmet showIf condition will not cause a 400 error.
Client-side: hidden fields are excluded from the submission payload.
Example — select field controls a conditional textarea:
POST /api/forms
-d '{"name": "Support Request", "fields": [
{"name": "category", "label": "Category", "type": "select", "options": ["Bug", "Feature", "Other"], "required": true},
{"name": "bug_details", "label": "Describe the bug", "type": "textarea", "required": true, "showIf": {"field": "category", "equals": "Bug"}},
{"name": "feature_details", "label": "Describe the feature", "type": "textarea", "required": true, "showIf": {"field": "category", "equals": "Feature"}},
{"name": "email", "label": "Email", "type": "email", "required": true}
]}'
When category is "Bug", bug_details appears and is required. When category is "Feature", feature_details appears instead. When category is "Other", neither textarea renders and neither is required.
Rules:
- The dependency field (field) must exist in the form's field list.
- equals matches the exact string value of the dependency field.
- Only one level of dependency is supported (no chaining showIf → showIf).
- Works on the hosted form (/f/ID), the embed snippet, and custom HTML forms (client must implement show/hide logic).
## Error Codes
Every error returns JSON: { "error": "message", ...details }
| Status | When | Response shape | Recovery |
| 400 | Validation failed or missing required fields | { error: "Validation failed", fieldErrors: [{ field, message }] } | Check fieldErrors array, fix input, retry |
| 401 | Missing, invalid, or expired API key | { error: "Unauthorized" } | Check Authorization header. If trial key expired, POST /api/trial for a new one or upgrade |
| 403 | Insufficient permissions or publicResults not enabled | { error: "Forbidden" } | Enable publicResults on the form, or use an authenticated key |
| 404 | Resource does not exist | { error: "Not found" } | Verify the ID. Resource may have been deleted |
| 409 | uniqueBy constraint violated (duplicate submission) | { error: "Duplicate submission", fields: ["email"] } | User already submitted — expected, not a bug |
| 410 | Form closed (past closesAt) or full (maxSubmissions reached) | { error: "Form is closed" } or { error: "Form has reached maximum submissions" } | Extend closesAt, increase maxSubmissions, or create a new form |
| 429 | Rate limited | { error: "Too many requests" } | Wait and retry. Trial: 5 keys/day per IP. Submissions: per-form rate limit |
| 500 | Server error | { error: "Internal server error" } | Retry after a few seconds. If persistent, contact support@sutrena.com |
Things to know:
- 400 on submit always includes fieldErrors[] — use them to show per-field messages
- 401 means the key is invalid or missing — check the Authorization header
- 409 only fires when ALL uniqueBy fields match — partial matches go through
- 410 is permanent for that form — check closesAt and maxSubmissions
## Response Envelope
Every authenticated API response wraps data in this structure:
{ "data": { ...resource... }, "_meta": { "plan": "free", "limits": { "projects": 3, "submissions_per_form": 100, ... }, "upgrade": { "url": "/pricing", "message": "..." }, "claim": { "deadline": "ISO datetime", "remainingMinutes": 1420, "url": "https://sutrena.com/claim?trial=...", "message": "..." } } }
_meta.claim is only present for unclaimed trial accounts. _meta.upgrade is only present for the free plan. Agents should parse _meta.claim.remainingMinutes to warn users about expiry.
## Response Examples
### POST /api/trial → 201
{ "data": { "key": "st_trial_abc123...", "plan": "free", "limits": { "projects": 3, "submissions_per_form": 100, "webhooks": 1, "custom_domains": 0, "subdomains": 1, "storage_bytes": 20971520, "asset_size_limit": 2097152, "events_per_month": 5000 }, "restrictions": ["No CSV export", "No upsert API"], "claimDeadline": "2026-03-07T12:00:00Z", "claimUrl": "https://sutrena.com/claim?trial=st_trial_abc123", "claimNote": "Data persists for 24 hours. Visit claimUrl to sign in and keep permanently.", "subdomain": "site-a1b2c3d4", "subdomainUrl": "https://site-a1b2c3d4.sutrena.com" }, "_meta": { ... } }
### POST /api/pages → 201
{ "data": { "id": "pg_uuid", "slug": "index", "title": "My Page", "pageUrl": "https://sutrena.com/p/pg_uuid", "subdomainUrl": "https://site-abc123.sutrena.com/", "isPublished": true, "sizeBytes": 4096, "viewCount": 0, "subdomainId": "sub_uuid", "createdAt": "2026-03-06T...", "updatedAt": "2026-03-06T..." }, "_meta": { ... } }
### GET /api/pages → 200
{ "data": { "pages": [{ "id": "pg_uuid", "slug": "index", "title": "My Page", "isPublished": true, "viewCount": 42, "sizeBytes": 4096, "pageUrl": "https://sutrena.com/p/pg_uuid", "subdomainUrl": "https://alice.sutrena.com/", "createdAt": "...", "updatedAt": "..." }], "count": 1, "total": 1, "limit": 100, "offset": 0 }, "_meta": { ... } }
Filters & pagination:
- ?subdomainId=sub_uuid — filter by subdomain
- ?slug=about — filter to a single slug (verify a specific page exists / was deployed)
- ?folderId=folder-uuid or ?folderId=none — filter by folder
- ?collection=blog — filter by metadata.collection
- ?metadata.foo=bar — generic metadata filter
- ?limit=N (1-1000, default 100) and ?offset=N (default 0) — paginate
- Response includes \`total\` (full match count), \`count\` (current page size), \`limit\`, \`offset\`
### POST /api/forms → 201
{ "data": { "id": "frm_uuid", "formId": "nk_uuid", "name": "Waitlist", "fields": [{ "name": "email", "label": "Email", "type": "email", "required": true }], "submitUrl": "https://sutrena.com/api/forms/nk_uuid/submit", "hostedFormUrl": "https://sutrena.com/f/nk_uuid", "embedCode": "\\n", "createdAt": "..." }, "_meta": { ... } }
### GET /api/forms/:id/submissions → 200 (paginated)
{ "data": { "data": [{ "id": "sub_uuid", "formId": "nk_uuid", "externalId": null, "payload": { "email": "user@example.com", "name": "Jane" }, "status": "clean", "createdAt": "2026-03-06T...", "updatedAt": "2026-03-06T..." }], "cursor": "eyJpZCI6Ii4uLiJ9" }, "_meta": { ... } }
Query params: search (full-text), from/to (ISO dates), field+value (exact match), status (clean|spam), limit (default 50, max 100), cursor (opaque string for next page).
Pagination: cursor-based. If cursor is present in response, pass it as ?cursor=... to get the next page. When cursor is null or absent, you've reached the end.
### POST /api/webhooks → 201
{ "data": { "id": "wh_uuid", "url": "https://hooks.slack.com/...", "events": ["form.submission"], "template": "slack", "isActive": true, "secret": "whsec_abc123...", "createdAt": "..." }, "_meta": { ... } }
IMPORTANT: secret is returned ONLY on creation. Store it securely — it's used for HMAC-SHA256 signature verification and cannot be retrieved later.
### GET /api/account → 200
{ "data": { "plan": "free", "email": "user@example.com", "name": "Jane", "provider": "github", "subscriptionStatus": "active", "cancelEffectiveAt": null, "createdAt": "..." }, "_meta": { ... } }
### GET /api/account/usage → 200
{ "data": { "plan": "free", "claimed": false, "usage": { "projects": { "current": 1, "limit": 3, "remaining": 2 }, "webhooks": { "current": 1, "limit": 1, "remaining": 0 }, "customDomains": { "current": 0, "limit": 0, "remaining": 0 }, "subdomains": { "current": 1, "limit": 1, "remaining": 0 }, "storage": { "current": 1048576, "limit": 20971520, "remaining": 19922944, "currentFormatted": "1MB", "limitFormatted": "20MB" }, "events": { "current": 500, "limit": 5000, "remaining": 4500 } }, "restrictions": ["No CSV export", "No upsert API"] }, "_meta": { ... } }
Note: limit: -1 means unlimited. remaining: -1 also means unlimited. Subdomain limits: Free: 1, Pro: 10, Scale: unlimited.
### POST /api/analytics/sites → 201
{ "data": { "site": { "id": "site_uuid", "name": "My Website", "domain": "alice.sutrena.com", "siteToken": "sa_abc123...", "eventsThisMonth": 0, "monthResetAt": "2026-04-01T...", "createdAt": "..." }, "scriptTag": "" }, "_meta": { ... } }
## Rate Limits
| Scope | Limit | Window | Notes |
| Trial key creation | 5 | 24 hours | Per IP address |
| Form submission | 60 | 1 minute | Per form per IP |
| Authenticated API calls | 120 | 1 minute | Per API key |
| MCP sessions | 5 concurrent | — | Per user, 30min inactivity cleanup |
| Event collection | 30 | 1 minute | Per IP address |
| Event collection | 10 | 1 second | Per site token |
429 responses include no Retry-After header. Wait 60 seconds and retry.
## Webhook Details
Only event type: form.submission. No other event types exist.
Retry schedule: 5 retries with exponential backoff (1s, 2s, 4s, 8s, 16s approximately).
Auto-deactivation: webhook is deactivated after 10 consecutive delivery failures.
Re-activation: PATCH /api/webhooks/:id with { "isActive": true } after fixing the endpoint.
Signature verification (Node.js):
const crypto = require('crypto');
const expected = 'sha256=' + crypto.createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex');
const valid = crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
## Pagination
Submissions use cursor-based pagination. GET /api/pages supports offset/limit pagination (?limit=100&offset=0, max limit 1000) — response includes \`total\` for the full match count. GET /api/forms returns all results — no pagination needed.
Request: GET /api/forms/:id/submissions?limit=50&cursor=eyJpZCI6Ii4uLiJ9
Response: { "data": { "data": [...submissions], "cursor": "next_cursor_string_or_null" } }
To paginate: keep calling with ?cursor=PREVIOUS_CURSOR until cursor is null.
Default limit: 50. Maximum: 100.
## File Upload Details
For presigned uploads (assets and form file fields), the PUT request to uploadUrl must:
1. Use the exact Content-Type that was specified in the presign request (e.g. "image/png")
2. Include any headers returned in uploadHeaders (for CSV uploads)
3. Send the raw file bytes as the request body (not form-data, not base64)
Example (TypeScript):
const presign = await fetch('https://sutrena.com/api/pages/assets', {
method: 'POST',
headers: { 'Authorization': \`Bearer \${KEY}\`, 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: 'hero.png', contentType: 'image/png', sizeBytes: file.size }),
}).then(r => r.json());
await fetch(presign.data.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': 'image/png' },
body: fileBuffer,
});
// Use presign.data.publicUrl in your HTML:
## Common Agent Mistakes
1. Scaffolding a local project — Sutrena is a hosted API. Do NOT create folders, install npm packages, run dev servers, or set up hosting. Just POST JSON to the API.
2. Exposing API keys in HTML/JS — API keys (st_trial_, st_live_) are SECRET. Never include them in page HTML, client-side JavaScript, or embed snippets. Form submission (POST /api/forms/:id/submit) is PUBLIC and needs NO key.
3. Using /p/slug URLs — Always show the subdomainUrl (e.g. https://alice.sutrena.com/my-page) to users. The /p/slug URL works but is not user-friendly.
4. Not using slug 'index' for single pages — Single pages should ALWAYS use slug 'index' which serves at the clean root URL (subdomain.sutrena.com/) with no path. Using any other slug for a single page forces users to type a path.
5. Forgetting Content-Type header — All POST/PUT requests to the API require Content-Type: application/json. Without it, you'll get a 400 error.
6. Adding auth to public endpoints — POST /api/forms/:id/submit is PUBLIC. Do NOT add an Authorization header. Same for GET /api/forms/:id/results and the hosted form at /f/FORM_ID.
7. Fighting slug conflicts — POST /api/launch is idempotent (updates existing pages automatically). POST /api/pages/batch supports upsert: true. Only POST /api/pages (single) returns 409 — use PUT /api/pages/:existingPageId to update.
8. Ignoring _meta.claim — For trial users, every response includes _meta.claim with remaining time. Tell the user to visit claimUrl before time runs out, or their data will be deleted.
9. Showing instructions instead of executing — If you can make HTTP requests (curl, fetch, or any tool), call the Sutrena API yourself and return the live URLs. Do NOT show the user curl commands or code snippets to run. The user expects a deployed result, not a tutorial.
## Webhook Format
POST to your URL:
{ "id": "evt_uuid", "event": "form.submission", "timestamp": "2026-01-15T12:00:00Z", "data": { "email": "user@example.com", "name": "Jane" } }
Headers: X-Sutrena-Signature-256 (sha256=HMAC hex), X-Sutrena-Event, X-Sutrena-Delivery
Verify: HMAC-SHA256(body, webhook_secret). 5 retries with backoff. Auto-deactivates after 10 failures.
Field mapping: POST /api/webhooks with fieldMapping: {"email": "user_email"} remaps keys in data object.
template: "default" | "slack" | "discord" | "telegram" | "teams" | "google-chat" — formats payload for that service automatically.
Telegram requires telegramChatId in the webhook config (chat_id is included in the payload body).
Webhook payload structure (complete):
{ "id": "evt_uuid", "event": "form.submission", "timestamp": "2026-01-15T12:00:00.000Z", "data": { "id": "sub_uuid", "formId": "frm_uuid", "payload": { "email": "user@example.com", "name": "Jane" }, "status": "clean", "createdAt": "2026-01-15T12:00:00.000Z" } }
With fieldMapping: {"email": "user_email"}, the data.payload becomes { "user_email": "user@example.com", "name": "Jane" }.
With template: "slack", the entire payload is reformatted as a Slack-compatible message block.
With template: "discord", the payload is reformatted as a Discord embed.
With template: "telegram", the payload is reformatted as a Telegram Bot API sendMessage body (HTML parse mode).
With template: "teams", the payload is reformatted as an Adaptive Card for Microsoft Teams.
With template: "google-chat", the payload is reformatted as a Google Chat text message with markdown.
## Public Results API
GET /api/forms/:id/results — no auth. Returns { total, fields: { fieldName: [{value, count}] } } for select/checkbox/multiselect fields. Only works if publicResults: true on the form. Multiselect arrays are flattened — each selected option counted separately.
## Custom HTML Form
No auth required. CORS enabled. Returns 400 with fieldErrors[] on validation failure, 409 on duplicate, 410 if closed/full.
## Embed Snippet
Two lines. Works on Framer, Webflow, WordPress, Squarespace — any HTML page.
## Pages
Deploy static HTML pages via API. Served at /p/:slug with no auth.
POST /api/pages
-H "Authorization: Bearer \$KEY"
-d '{"slug": "my-landing", "title": "My Landing Page", "html": "
Welcome
Sign up below.
", "css": "body { font-family: sans-serif; }"}'
→ { "data": { "id": "...", "pageUrl": "/p/my-landing", "subdomainUrl": "https://site-abc123.sutrena.com/my-landing", "slug": "my-landing" } }
The response includes subdomainUrl if you have a subdomain set (auto-assigned on account creation).
Slug rules: lowercase alphanumeric + hyphens, 2-200 chars, must start/end with alphanumeric. Supports hierarchical paths with / separators (e.g. 'blog/my-post', 'archive/2026/march/article').
Max HTML: 512KB. Max CSS: 128KB.
Limits: Pages count toward the project pool. Free ${FREE_PROJECTS} projects, Pro ${PRO_PROJECTS}, Scale unlimited.
Update: PUT /api/pages/:id — change HTML/CSS/title. Slug is immutable. Pass ifUnmodifiedSince (ISO timestamp from a previous updatedAt) in the request body to prevent overwriting unseen changes — returns 409 with currentUpdatedAt if the page was modified after that time.
If POST returns 409 (slug exists), the response includes existingPageId — use PUT /api/pages/:id with that ID to update instead.
Delete: DELETE /api/pages/:id — entries are automatically archived before deletion (90-day retention). Response includes _warning if entries were archived.
Rollback: POST /api/pages/:id/rollback — swaps current content (html, css, entryTemplate) with the previous snapshot. Reversible (call again to undo). Returns 409 if no snapshot exists.
List: GET /api/pages → all your pages with viewCount.
### Batch Page Create/Upsert
POST /api/pages/batch — create or update up to 200 pages in one call. Pass upsert: true to update existing pages (with automatic snapshots) instead of erroring on slug conflicts. Updated pages get snapshots for rollback.
Request: { "pages": [{ "slug": "index", "title": "Home", "html": "
Home
" }, ...], "subdomainId": "sub_xyz" (optional), "upsert": true }
Response: { "data": { "results": [{ "index": 0, "status": "created"|"updated"|"unchanged"|"error", "id": "...", "slug": "index", "subdomainUrl": "https://..." }], "created": 1, "updated": 2, "unchanged": 0, "failed": 0 } }
Status codes: 201 (all created), 200 (all updated), 207 (mixed results), 400 (all failed validation), 402 (zero quota). Quota only counts net new pages, not updates.
Use for multi-page site deployments and re-deployments. Each page follows the same validation as POST /api/pages.
### Batch Page Delete
DELETE /api/pages/batch — delete up to 200 pages by ID in one call. Checks folder access per page. Entries are archived before deletion.
Request: { "pageIds": ["uuid1", "uuid2", ...] }
Response: { "data": { "results": [{ "id": "...", "status": "deleted"|"error", "error?": "..." }], "deleted": 2, "failed": 0, "entriesArchived?": 1 } }
Pages are public by default (isPublished: true). Set isPublished: false to unpublish.
publishAt: ISO datetime — schedule a page to auto-publish at a future time. Page is created as unpublished (isPublished: false) and automatically becomes published when the scheduled time arrives. Useful for timed launches, embargoed content, or coordinated multi-page rollouts.
Example: POST /api/pages with { "slug": "launch", "title": "Launch Day", "html": "...", "publishAt": "2026-04-01T09:00:00Z" } — page goes live at 9 AM UTC on April 1st.
CSP headers restrict scripts to inline only. No external script loading.
### Safe Deployment
Three safety mechanisms for page updates:
**Preview workflow:**
1. Create page with isPublished: false → the returned pageUrl (UUID-based, e.g. /p/UUID) works as a private preview link
2. Share the UUID URL with reviewers — only people with the link can see it, it doesn't appear on the subdomain
3. When approved, PUT /api/pages/:id with { "isPublished": true } to go live on the subdomain URL
**Automatic snapshots:**
Every content update (html, css, or entryTemplate change) automatically saves the previous version. GET /api/pages/:id returns hasPreviousVersion (boolean) and snapshotAt (timestamp). Call POST /api/pages/:id/rollback to swap current content with the snapshot. Rollback is reversible — call it again to swap back. Only the most recent snapshot is kept (one-deep). Rollback includes entryTemplate alongside html and css.
**Entry preservation:**
Entries are user-generated content and are never silently destroyed. All page deletions (single, batch, MCP, deploy stale policy) automatically archive entries before deleting the page. Archives are kept for 90 days.
- GET /api/pages/archives — list all archived entries for the current user.
- POST /api/pages/:id/restore-entries { archiveId } — merge archived entries into a target page. Also restores the entryTemplate if the page doesn't have one.
- Deploy preserveEntries: true (default in sutrena.json) — entryTemplate is preserved on existing pages unless the manifest explicitly sets it. Set preserveEntries: false to override.
**Staging pattern:**
Use multiple subdomains as environments: myapp-staging.sutrena.com (staging) + myapp.sutrena.com (production). Deploy and test on staging. When ready, swap a custom domain from staging to production subdomain instantly with PATCH /api/account/domains/:id { "subdomainId": "production-sub-id" } — no DNS changes, no SSL re-provisioning.
Zip deploys also create snapshots for existing pages that get updated. MCP tool: sutrena_rollback_page.
### Page Entries
Structured JSON data on a page, rendered server-side using an agent-defined HTML template. Use entries for blog posts, project cards, feed updates, link-in-bio items, changelogs — any repeated content on a single page.
The agent controls all design. Sutrena stores data and renders it.
**How it works:**
1. Create a page with an entryTemplate (HTML with {{placeholder}} or {{{placeholder}}} syntax) and a \`\` marker in the page HTML
2. Add entries via POST /api/pages/:id/entries with { data: { key: value } }
3. Sutrena renders entries newest-first at the marker position, replacing {{key}} with HTML-escaped values and {{{key}}} with raw/unescaped values
4. Visitors see the rendered page — no client-side JS needed
**Create a page with entry template:**
POST /api/pages
-d '{"slug": "blog", "title": "Blog", "html": "
"}'
Pass entryTemplate: null to remove the template. Max template size: 64KB. Max entries per page: 500. {{key}} values are HTML-escaped automatically (XSS-safe). {{{key}}} values are rendered raw/unescaped (for trusted HTML like pre-rendered SVGs). Missing keys render as empty string.
**Collection filtering:**
Tag pages with metadata.collection to group them. Then filter:
POST /api/pages -d '{"slug": "post-1", "title": "First Post", "html": "...", "metadata": {"collection": "blog"}}'
GET /api/pages?collection=blog → returns only pages with metadata.collection === "blog"
Use collections for blog index patterns: create individual post pages with metadata.collection = "blog", then GET /api/pages?collection=blog to build a table of contents.
MCP tool: sutrena_manage_page_entries — single tool with action: "add" | "list" | "delete". entryTemplate is set via sutrena_create_page, sutrena_update_page, or sutrena.json manifest.
### Multi-page sites
Create multiple pages under the same subdomain for a multi-page website:
1. Your subdomain is auto-assigned (e.g. site-a1b2c3d4.sutrena.com). Change it: PUT /api/account/subdomain { "subdomain": "myevent" }
2. Create pages: slug "index" → myevent.sutrena.com/, slug "speakers" → myevent.sutrena.com/speakers, slug "schedule" → myevent.sutrena.com/schedule
3. Hierarchical slugs: slug "blog/my-post" → myevent.sutrena.com/blog/my-post. Use / to create nested URL structures.
4. Bake navigation HTML into each page to link between them.
### Multi-subdomain control
Deploy pages to different subdomains under one account:
1. GET /api/account/subdomains — see all subdomains (each has page count)
2. POST /api/account/subdomains { "name": "blog" } — create new subdomain
3. POST /api/pages { ..., "subdomainId": "sub_xyz" } — deploy to specific subdomain
4. Omit subdomainId to deploy to your default/first subdomain
Use case: alice.sutrena.com (personal), blog.sutrena.com (blog), docs.sutrena.com (docs) — all from one API key.
## Custom Subdomains
A random subdomain (e.g. site-a1b2c3d4) is auto-assigned when your account is created.
List all subdomains:
GET /api/account/subdomains
→ [{ "id": "sub_xyz", "name": "alice", "pageCount": 3, "url": "https://alice.sutrena.com" }, ...]
Create new subdomain:
POST /api/account/subdomains
-H "Authorization: Bearer \$KEY"
-d '{"name": "blog"}'
→ { "id": "sub_abc", "name": "blog", "url": "https://blog.sutrena.com" }
Change default subdomain (deprecated, prefer creating + specifying subdomainId):
PUT /api/account/subdomain
-H "Authorization: Bearer \$KEY"
-d '{"subdomain": "alice"}'
→ { "subdomain": "alice", "url": "https://alice.sutrena.com" }
Rules: 3-30 chars, lowercase alphanumeric + hyphens. Must start/end with alphanumeric.
Reserved: www, api, app, admin, dashboard, cdn, assets, staging, dev, mail, etc.
Available on all plans. Pages become accessible at subdomain.sutrena.com/slug. Root path (/) serves slug "index".
Environment pattern: Use multiple subdomains as separate environments. Create myapp.sutrena.com (production), myapp-staging.sutrena.com (staging), myapp-preview.sutrena.com (preview). Deploy to staging first, verify, then either deploy the same content to production or switch your custom domain to point at the staging subdomain. Switch is instant — no DNS changes, no SSL re-provisioning. See the environment workflow guide: https://sutrena.com/guides/environment-workflow-subdomains
Workflow for multi-subdomain deployment:
1. GET /api/account/subdomains → see all available subdomains
2. Pick one from the list, or create a new one with POST /api/account/subdomains
3. POST /api/pages with "subdomainId": "sub_xyz" to deploy to that subdomain
4. If subdomainId is omitted, page deploys to your first/default subdomain
## Custom Domains
Each custom domain serves pages from one specific subdomain. When you add mysite.com and link it to alice.sutrena.com, both URLs serve the same pages simultaneously.
POST /api/account/domains
-H "Authorization: Bearer \$KEY"
-d '{"domain": "mysite.com", "subdomainId": "optional-subdomain-id"}'
→ { "id": "...", "domain": "mysite.com", "subdomainId": "...", "subdomainName": "alice", "dnsStatus": "pending", "sslStatus": "pending", "instructions": {...} }
If subdomainId is omitted, the domain is linked to your first/default subdomain. Get subdomain IDs via GET /api/account/subdomains.
PATCH /api/account/domains/:id — { "subdomainId": "..." } to change which subdomain a domain serves from.
GET /api/account/domains/:id — re-verifies DNS/SSL via Cloudflare. Returns subdomainId/subdomainName.
DELETE /api/account/domains/:id — removes domain.
Limits: Free 0, Pro 5, Scale unlimited.
IMPORTANT: Deploy pages to the correct subdomain BEFORE adding a custom domain. The domain only serves pages from its linked subdomain. If you have multiple subdomains, always specify subdomainId.
## Static Assets
Upload images, videos, and other files for use in pages.
POST /api/pages/assets
-H "Authorization: Bearer \$KEY"
-d '{"filename": "hero.png", "contentType": "image/png", "sizeBytes": 524288}'
→ { "assetId": "...", "uploadUrl": "https://...", "publicUrl": "https://assets.sutrena.com/pages/..." }
1. Call POST /api/pages/assets to get presigned upload URL
2. PUT file to uploadUrl
3. Use publicUrl in your page HTML
Size limits per plan: Free 2MB/file (20MB total), Pro 20MB/file (2GB total), Scale 50MB/file (unlimited total).
### Batch Asset Upload
POST /api/pages/assets/batch — presign up to 20 asset uploads in one call.
Request: { "assets": [{ "filename": "hero.png", "contentType": "image/png", "sizeBytes": 102400 }, ...] }
Response: { "data": { "results": [{ "index": 0, "status": "created", "assetId": "...", "uploadUrl": "...", "uploadHeaders": {}, "publicUrl": "...", "filename": "hero.png", "sizeBytes": 102400 }, ...], "created": N, "failed": M } }
Status codes: 201 (all created), 207 (partial success), 400 (all validation fail), 402 (quota exceeded).
Single quota check for total bytes. Per-asset size limit check against plan. Upload files in parallel to the returned uploadUrls.
## Analytics
Privacy-first web analytics built into Sutrena. No cookies, no personal data, no IP storage, GDPR-compliant out of the box. Add one script tag to any page — works on Sutrena-hosted pages and external sites alike.
### Tracking Script
The script is tiny (~1KB), loads asynchronously, and auto-tracks:
- Page views on initial load
- SPA navigation via pushState/popstate (React Router, Next.js, Vue Router, etc.)
- Referrer, screen width, country (via request headers, not geolocation API)
- Device type, browser, OS (parsed from User-Agent)
Bot traffic is automatically filtered. Events are sent via navigator.sendBeacon (falls back to XMLHttpRequest).
### Custom Events
Track custom events from client-side JavaScript:
window.sutrena.track("signup", { plan: "pro" });
window.sutrena.track("purchase", { product: "widget", amount: 29.99 });
Custom event properties are stored as JSON (max 4KB per event).
### Three Query Primitives
**1. Query — Metrics over time with breakdowns**
GET /api/analytics/query?siteId=SITE_ID&metric=page_views&period=30d&groupBy=day&breakdownBy=url
Metrics: page_views, unique_visitors, visits, bounce_rate, avg_session_duration, views_per_visit, active_visitors, avg_time_on_page
Periods: today, 7d, 30d, 90d, custom (with from/to ISO dates)
Granularity (groupBy): day, week, month, hour
Breakdowns (breakdownBy): url, referrer, country, city, region, device, browser, os, utm_source, utm_medium, utm_campaign, entry_page, exit_page, channel
Filters: filter.url=/about, filter.country=US, filter.utm_source=twitter (prefix with filter.)
Compare: compare=true to get previous period comparison
Custom date range: period=custom&from=2026-03-01&to=2026-03-15 (capped at plan retention limit)
UTM parameters are extracted automatically from page URLs — no tracking script changes needed.
avg_session_duration returns seconds (multi-event sessions only). active_visitors is point-in-time (last 5 minutes, ignores period). Also available via GET /api/analytics/realtime?siteId=ID → { activeVisitors }. avg_time_on_page returns seconds from page_leave events (requires a.js tracking script). entry_page/exit_page show landing and exit URLs per session. channel auto-groups traffic into: direct, organic, social, paid, email, referral.
**2. Funnel — Multi-step conversion analysis**
POST /api/analytics/funnel
-d '{"siteId": "SITE_ID", "steps": [{"event": "pageview"}, {"event": "signup"}, {"event": "purchase"}], "period": "30d"}'
2-5 steps per funnel. Each step specifies an event name. Returns visitor counts and drop-off rates between steps. Supports custom date ranges via from/to.
**3. Retention — Cohort return rates**
POST /api/analytics/retention
-d '{"siteId": "SITE_ID", "firstEvent": "pageview", "returnEvent": "pageview", "period": "30d", "granularity": "week"}'
Measures how many visitors who performed firstEvent come back and perform returnEvent. Granularity: day, week, month. Supports custom date ranges via from/to.
**4. Sessions — Individual visitor journeys**
GET /api/analytics/sessions?siteId=SITE_ID&period=today&minPages=2&limit=50
Returns paginated list of sessions with page-by-page paths, duration, geo (country/city/region), and device. Limited to 7-day windows (period: today, 7d, or custom with max 7-day range). Supports filters: filter.country, filter.city, filter.region, filter.device. Pagination via limit (max 100) and offset.
### Setting Up Analytics
1. POST /api/analytics/sites with { "name": "My Site", "domain": "alice.sutrena.com" }
2. Response includes siteToken and a ready-to-use scriptTag
3. Add the script tag to your page HTML (or use the MCP tool sutrena_create_analytics_site)
4. Events flow in automatically — query them via the API
### Event Limits & Retention
Events are counted per calendar month. Counter resets on the 1st of each month.
- Free: 5,000 events/month, 90-day retention
- Pro: 500,000 events/month, 365-day retention
- Scale: Unlimited events, 730-day retention
When the monthly limit is reached, new events are silently dropped (no errors, no broken pages). Upgrade for more capacity.
## Automations
DSL-based automation pipelines — declarative step sequences that connect Sutrena primitives. No code, no infrastructure. Define a trigger, list the steps, deploy via API.
### Triggers (${TRIGGER_TYPE_COUNT} types)
**1. form_submission** — fires when a form receives a submission
{ "type": "form_submission", "formId": "FORM_ID" }
IMPORTANT: The form creation response (POST /api/forms) returns TWO IDs:
- data.id — Sutrena's internal config ID (do NOT use for automations)
- data.formId — the form ID to use in automation triggers and steps
Trigger data available as {{trigger.fieldName}} in steps (e.g., {{trigger.email}}, {{trigger.name}}).
**2. http** — exposes a public URL at subdomain.sutrena.com/fn/{slug}
{ "type": "http" }
Accepts GET/POST. Request body/query params available as {{trigger.key}}. Use a respond step to return data.
**3. schedule** — runs on a cron expression (5-field, UTC)
{ "type": "schedule", "cron": "0 9 * * 1" }
Examples: "0 9 * * 1" (Monday 9 AM), "0 */6 * * *" (every 6 hours), "0 0 1 * *" (first of month).
Minimum intervals: Free: 60 min, Pro: 5 min, Scale: 1 min.
### Steps (${STEP_TYPE_COUNT} types)
Steps execute sequentially. Use outputAs to name a step's result, then access it downstream via {{steps.name}}.
| Type | What it enables | Key fields |
| fetch | Call any external API (LLMs, payments, CRMs). JSON responses auto-parsed — access nested fields via {{steps.name.body.path.to.field}} | url, method?, headers?, body?, outputAs, timeout? |
| condition | Route to different steps based on values | field, op (eq/neq/gt/lt/gte/lte/contains/exists/not_exists), value, then[], else?[] |
| create_entry | Add content to a page (comments, testimonials, blog posts) | pageId, data |
| update_page | Rewrite page HTML/CSS dynamically | pageId, html?, css? |
| update_submission | Tag, enrich, or change status on a submission | formId, submissionId, payload |
| create_submission | Write structured data into a form | formId, data |
| update_form | Change form fields or settings dynamically | formId, updates |
| set | Define variables for downstream steps | variables: { name: "value" } |
| query_analytics | Read a metric (page_views, unique_visitors, visits, bounce_rate, avg_session_duration, views_per_visit, active_visitors, avg_time_on_page) with optional breakdowns | siteId, metric, period, breakdownBy?, outputAs |
| get_submission_count | Count submissions with optional filters | formId, filters?, outputAs |
| track_event | Record a custom analytics event | siteId, name, url?, properties? |
| respond | Return HTTP response (http trigger only) — build APIs | status?, headers?, body |
### Template Interpolation
All string fields in steps support interpolation:
- {{trigger.fieldName}} — form submission field, HTTP request body/query param, or schedule context
- {{steps.varName}} — result of a previous step (fetch response, query result, set variable)
- {{steps.varName.body.nested.field}} — dot notation into JSON objects (e.g. fetch response body)
- {{env.KEY}} — encrypted environment variable (store API keys, tokens, secrets)
- Hyphens are supported in keys: {{steps.result.headers.content-type}}, {{trigger.x-request-id}}
### Environment Variables (Encrypted)
Store secrets encrypted at rest (AES-256-GCM). Set via env field on automation creation/update.
Access in any step via {{env.KEY}}. Never appear in logs or API responses.
Plan limits: Free: 3 env vars, Pro: 10, Scale: 25.
### Recipe 1: AI Content Moderation (form_submission → fetch → condition → create_entry)
Incoming comment → call an LLM to classify → only publish if approved. No external tools needed.
POST /api/automations
{ "name": "AI Moderator", "slug": "moderate-comments",
"trigger": { "type": "form_submission", "formId": "COMMENT_FORM_ID" },
"env": { "OPENAI_KEY": "sk-..." },
"steps": [
{ "type": "fetch", "method": "POST", "url": "https://api.openai.com/v1/chat/completions",
"headers": { "Content-Type": "application/json", "Authorization": "Bearer {{env.OPENAI_KEY}}" },
"body": { "model": "gpt-4o-mini", "messages": [{ "role": "system", "content": "Reply ONLY true or false. Is this a genuine comment (not spam)?" }, { "role": "user", "content": "{{trigger.comment}}" }] },
"outputAs": "llm" },
{ "type": "condition", "field": "steps.llm.body.choices.0.message.content", "op": "contains", "value": "true",
"then": [
{ "type": "create_entry", "pageId": "COMMENTS_PAGE_ID",
"data": { "name": "{{trigger.name}}", "comment": "{{trigger.comment}}" } }
] }
] }
Note: fetch body accepts JSON objects (auto-serialized) or escaped JSON strings. Objects are recommended — they support {{template}} interpolation and avoid escaping issues.
Works with any API: Anthropic, Mistral, Cohere, or your own endpoint. The fetch step calls any URL — Sutrena doesn't need to know what's behind it.
### Recipe 2: Weekly Traffic Digest (schedule → query_analytics → get_submission_count → fetch)
Every Monday, pull analytics and submission stats, post a summary to Slack.
POST /api/automations
{ "name": "Weekly Digest", "slug": "weekly-digest",
"trigger": { "type": "schedule", "cron": "0 9 * * 1" },
"steps": [
{ "type": "query_analytics", "siteId": "SITE_ID", "metric": "page_views", "period": "7d", "outputAs": "views" },
{ "type": "get_submission_count", "formId": "FORM_ID", "outputAs": "signups" },
{ "type": "fetch", "method": "POST", "url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL",
"headers": { "Content-Type": "application/json" },
"body": { "text": "Weekly digest: {{steps.views}} page views, {{steps.signups}} signups" },
"outputAs": "slack" }
] }
### Recipe 3: API Gateway (http → fetch → respond)
Expose a public endpoint that proxies/transforms an external API. No server required.
POST /api/automations
{ "name": "Weather API", "slug": "weather",
"trigger": { "type": "http" },
"env": { "WEATHER_KEY": "abc123" },
"steps": [
{ "type": "fetch", "url": "https://api.weather.com/v1/current?city={{trigger.city}}&key={{env.WEATHER_KEY}}",
"outputAs": "wx" },
{ "type": "respond", "status": 200,
"body": { "city": "{{trigger.city}}", "temp": "{{steps.wx.body.temp}}", "conditions": "{{steps.wx.body.description}}" } }
] }
Live at: https://your-subdomain.sutrena.com/fn/weather?city=Tokyo
### Recipe 4: Slug Conflict Handling (http → fetch → condition → respond)
Create pages safely — detect conflicts, return meaningful errors. POST /api/pages returns 409 with existingPageId when slug is taken.
POST /api/automations
{ "name": "Safe Page Creator", "slug": "create-page",
"trigger": { "type": "http" },
"env": { "KEY": "st_live_..." },
"steps": [
{ "type": "fetch", "method": "POST", "url": "https://sutrena.com/api/pages",
"headers": { "Authorization": "Bearer {{env.KEY}}", "Content-Type": "application/json" },
"body": { "slug": "{{trigger.slug}}", "title": "{{trigger.title}}", "html": "{{trigger.html}}" },
"outputAs": "result" },
{ "type": "condition", "field": "steps.result.status", "op": "eq", "value": 409,
"then": [
{ "type": "respond", "status": 409,
"body": { "error": "slug_taken", "existingId": "{{steps.result.body.existingPageId}}" } }
],
"else": [
{ "type": "respond", "status": 201,
"body": { "id": "{{steps.result.body.data.id}}", "url": "{{steps.result.body.data.subdomainUrl}}" } }
] }
] }
Key points: fetch body accepts objects (auto-serialized). Condition branches on HTTP status. Respond forwards nested body fields via {{steps.result.body.path}}.
### Test Runs
POST /api/automations/:id/test
Executes all steps for real but does NOT increment run counters or write log entries. Use to verify before going live.
### Execution Logs
GET /api/automations/:id/logs
Returns: [{ triggeredBy, status, stepsExecuted, durationMs, error, createdAt }]
Logs are immutable. Each real execution (not test) creates one log entry.
### Failures (Cross-Automation)
GET /api/automations/failures?limit=50
Scope: automations:read. Returns all error/timeout logs across all automations for the user.
Includes automationName and automationSlug in each entry. Quota-exceeded runs are logged here.
Default limit 50, max 100.
### Plan Limits
| | Free | Pro | Scale |
| Automations | Part of projects pool | Part of projects pool | Unlimited |
| Runs/month | ${FREE_AUTOMATION_RUNS} | ${PRO_AUTOMATION_RUNS.toLocaleString()} | ${SCALE_AUTOMATION_RUNS.toLocaleString()} |
| Max steps | 10 | 20 | 50 |
| Fetch steps | 2 | 5 | 10 |
| Env vars | 3 | 10 | 25 |
| Min schedule interval | 60 min | 5 min | 1 min |
Automations count toward the projects pool (forms + pages + analytics sites + automations).
Monthly run counter resets on the 1st of each month.
## Folders (Organize Resources)
Group forms, pages, analytics sites, and automations into folders. Folders are unlimited on all plans — they're organizational only and don't count toward project quota.
### Create a folder
POST /api/folders
{ "name": "Marketing Site", "description": "Landing pages and forms for Q2 campaign" }
→ { id, name, description, createdAt }
### List folders with resource counts
GET /api/folders
→ [{ id, name, description, formCount, pageCount, analyticsSiteCount, createdAt }]
Also returns ungrouped counts (resources not in any folder).
### Move resources into a folder
POST /api/folders/:id/organize
{ "formIds": ["f1", "f2"], "pageIds": ["p1"] }
→ { moved: { forms: 2, pages: 1, analyticsSites: 0 } }
### Remove resources from a folder
POST /api/folders/:id/ungroup
{ "pageIds": ["p1"] }
→ Sets folderId to null on specified resources (does NOT delete them).
### Delete a folder
DELETE /api/folders/:id
→ Ungroups all resources (sets folderId to null), then deletes folder. Never destroys resources.
### Create resources in a folder
All create endpoints accept optional folderId:
- POST /api/forms { "name": "...", "fields": [...], "folderId": "folder-uuid" }
- POST /api/pages { "slug": "...", "html": "...", "folderId": "folder-uuid" }
- POST /api/analytics/sites { "name": "...", "folderId": "folder-uuid" }
### Filter by folder
All list endpoints accept ?folderId= filter:
- GET /api/forms?folderId=folder-uuid
- GET /api/pages?folderId=folder-uuid
- GET /api/analytics/sites?folderId=folder-uuid
Use ?folderId=none to list ungrouped resources.
### Folder-scoped API keys
Create an API key scoped to a folder:
POST /api/keys { "label": "marketing-agent", "folderId": "folder-uuid" }
Scoped keys enforce folder boundaries on all operations:
- **List**: Only returns resources in the scoped folder (pages, forms, analytics sites, automations)
- **Get/Update/Delete**: Returns 403 if the resource is outside the scoped folder
- **Create**: Automatically assigns new resources to the scoped folder (no explicit folderId needed)
- **Compound tools**: launch, collect, and status all respect folder scope
Session auth always has full access regardless of folder scope.
## Granular API Key Scopes
API keys support fine-grained permissions using a resource:action format.
### 16 resources
forms, submissions, pages, entries, analytics, events, deploy, assets, webhooks, subdomains, domains, folders, account, emails, automations
### 4 actions
read, write, delete, * (all actions for a resource)
### Special scope
submissions:export — CSV export (separated for data sensitivity)
### Wildcards
- forms:* — all operations on forms
- *:read — read access to everything
- *:* — full access (default for keys without scopes)
### Creating a scoped key
POST /api/keys
{ "label": "read-only-agent", "scopes": ["pages:read", "forms:read", "analytics:read"] }
### Combining scopes with folder restriction
POST /api/keys
{ "label": "marketing-writer", "folderId": "folder-uuid", "scopes": ["pages:*", "forms:*", "analytics:read"] }
→ Key can read/write/delete pages and forms, but only read analytics — and only within the specified folder.
### Instance-level restrictions (resourceIds)
POST /api/keys
{ "label": "single-form-key", "scopes": ["forms:read", "submissions:read"], "resourceIds": { "forms": ["form-uuid-1"] } }
→ Key can only access the specific form and its submissions.
### Key delegation
API keys can create child keys via POST /api/keys (not just session auth). Child keys cannot escalate beyond parent permissions — scopes must be a subset, folder must match, resourceIds must be a subset.
### Key expiry
Root keys support expiresAt (max 90 days):
POST /api/keys { "label": "temp-key", "expiresAt": "2026-04-15T00:00:00Z" }
### Backward compatibility
Existing keys with scopes: null are treated as *:* (full access). No migration required.
## Deploying a Multi-Page Site (The 3-Call Pattern)
An agent deployed a 17-page Astro site with 8 images, shared CSS, and JS. Here's the optimal workflow — 3 API calls total:
**Step 1: Batch-presign all assets (1 call)**
POST /api/pages/assets/batch with all images, fonts, and large JS files.
Returns presigned URLs for each. Upload them all in parallel (PUT to each uploadUrl — these are direct R2 uploads, no auth needed, fire them all at once).
**Step 2: Set shared CSS and JS on the subdomain (1 call)**
PUT /api/account/subdomains/:id with { "sharedCss": "...", "sharedJs": "..." }
Every page under this subdomain automatically gets this CSS in and JS before .
No more duplicating 15KB of CSS × 17 pages. Works for custom domains too.
**Step 3: Batch-create all pages (1 call)**
POST /api/pages/batch with up to 200 pages. Each page only needs its unique HTML — the shared CSS/JS is handled by step 2.
Use hierarchical slugs: "archive/my-article" serves at subdomain.sutrena.com/archive/my-article.
Use slug "index" for the homepage (serves at root path).
**Total: 3 sequential API calls + N parallel uploads.** Before batch APIs, this same deploy took 30+ sequential calls.
Works with any SSG output:
- Astro → dist/ folder structure maps to slugs
- Hugo → public/ folder
- Plain HTML → just POST directly
- Any SSG output → same pattern: scan for assets, upload, set shared resources, batch pages
## Zip Site Deployment
Deploy an entire SSG build (Astro, Hugo, Next.js static export, etc.) by uploading a zip file. The server handles everything: extracting HTML pages, uploading assets to CDN, setting shared CSS/JS, and creating pages. Processing runs asynchronously — the API returns 202 immediately and you poll for completion.
**Step 1: Presign the zip upload**
POST /api/deploy -d '{ "sizeBytes": 5000000 }'
→ { "data": { "deployId": "obj_abc", "uploadUrl": "https://..." } }
**Step 2: Upload the zip**
PUT --upload-file dist.zip
**Step 3: Start processing (returns immediately)**
POST /api/deploy/obj_abc/process -d '{ "subdomainId": "sub_xyz" }'
→ 202 { "data": { "deployId": "obj_abc", "status": "processing", "statusUrl": "/api/deploy/obj_abc", "pollIntervalMs": 2000 } }
**Step 4: Poll for completion**
GET /api/deploy/obj_abc
→ { "data": { "status": "processing|done|failed|partial", "progress": { "phase": "uploading_assets", "assetsTotal": 320, "assetsDone": 240, "pagesTotal": 0, "pagesDone": 0 }, "result": null, "error": null } }
When status reaches \`done\` (or \`partial\`), the \`result\` field contains the full DeployResult: \`{ pages, assets, siteUrl, stalePagesNotInZip, errors, stats }\`. On \`failed\`, \`error\` contains the message.
Stale-row recovery: deploys stuck in \`processing\` for >15 minutes are auto-marked as \`failed\` on the next GET. The SDK auto-polls in \`deploy.process()\` — you don't need to write the loop yourself. Use \`deploy.processAsync()\` + \`deploy.status()\` if you want to poll manually.
The server auto-detects root directories (dist/, out/, build/, _site/), classifies files (HTML → pages, CSS/JS → shared resources, images → CDN assets, convention files → raw content-type pages), rewrites asset paths in HTML, and creates/updates pages.
Convention files auto-detected as raw pages: robots.txt, sitemap.xml, manifest.json, manifest.webmanifest, feed.xml, rss.xml, atom.xml, llms.txt, llms-full.txt, humans.txt, ads.txt, app-ads.txt, security.txt, browserconfig.xml, opensearch.xml. Only detected at the root level (not in subdirectories).
Max zip size: Free 50MB, Pro 200MB, Scale 500MB.
Re-deploy: existing pages with matching slugs are updated, new pages are created, pages on the subdomain not in the zip are listed in stalePages (not auto-deleted, unless sutrena.json says otherwise). Pass \`prune: true\` in the process request body to auto-delete stale pages not in the zip (entries are archived before deletion).
### Deploy Manifest (sutrena.json)
Place a sutrena.json file in the zip root for declarative per-page configuration and redirects. JSON Schema: GET /api/schemas/deploy
\`\`\`json
{
"$schema": "https://sutrena.com/schemas/deploy.json",
"pages": {
"blog": {
"entryTemplate": "
{{title}}
{{{html}}}",
"metadata": { "type": "feed" }
},
"draft-post": { "isPublished": false }
},
"redirects": [
{ "from": "old-post", "to": "new-post" }
],
"stalePolicy": "delete",
"preserveEntries": true
}
\`\`\`
**pages**: per-slug config. Keys are slugs (matching HTML filenames after conversion: about.html → "about", blog/index.html → "blog").
- entryTemplate: HTML template for entries. Use {{key}} (escaped) or {{{key}}} (raw). Set to null to clear.
- entryTemplateFile: path to an HTML file in the zip (e.g. "_templates/blog-entry.html"). Mutually exclusive with entryTemplate.
- metadata: arbitrary JSON metadata on the page.
- isPublished: false to keep page private (default: true).
**redirects**: array of { from, to }. Served as 301 redirects. Max 100. Replaced entirely on each deploy. When a visitor requests the "from" slug, they get a 301 to the "to" slug.
**stalePolicy**: how to handle pages on the subdomain NOT in the zip.
- "warn" (default): list them in stalePagesNotInZip in the deploy response
- "delete": auto-delete them (entries are archived before deletion)
- "keep": ignore them entirely (suppress stale page reporting)
**preserveEntries**: true (default) | false. When true, entryTemplate is preserved on existing pages unless the manifest explicitly sets it. When false, entryTemplate follows manifest strictly (cleared if not specified).
Files in _-prefixed directories (except _astro, _next, _site) are skipped — NOT deployed as pages. Use _templates/ for entryTemplateFile references.
Parse errors are non-fatal — deploy continues without manifest, error added to errors[]. If no sutrena.json, behavior is identical to before.
Note: Slugs are immutable. PUT /api/pages/:id returns 400 if you attempt to change a slug. Use redirects in sutrena.json to handle URL changes.
## Shared Subdomain CSS and JS
PUT /api/account/subdomains/:id to set shared CSS and JS for all pages under a subdomain:
{ "sharedCss": "body { font-family: sans-serif; } ...", "sharedJs": "console.log('loaded');" }
- sharedCss: injected as