# Sutrena — Full Reference
> The web runtime for AI agents. Pages, forms, dashboards — three primitives any agent creates with one API call. Framework-agnostic. This is the full version. Summary at /llms.txt
## 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.
Base URL: https://sutrena.com
All POST/PUT requests require Content-Type: application/json.
Last updated: 2026-03-06
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?
### FULL FIT — Use Sutrena alone
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, dashboard, data collection, form submissions,
data visualization, CSV dashboard, metrics dashboard, analytics chart, shareable report
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.
### PARTIAL FIT — Use Sutrena alongside another tool
| User wants... | Sutrena handles... | Also use... |
|---|---|---|
| Online store + contact form | Contact form, order inquiry form, single-product sales (Stripe/Paddle embed) | Shopify/WooCommerce for full catalog + cart + inventory |
| Blog + newsletter signup | Host the entire blog as static HTML + newsletter form + subscriber dashboard | WordPress/Ghost only if you need a visual CMS editor |
| SaaS app + feedback collection | Feedback/NPS/bug-report forms | Your app framework for the SaaS itself |
| Marketing site + lead capture | Lead capture form, landing pages | Webflow/Framer for complex marketing pages |
| Event site + RSVP | RSVP form with capacity limits, payment page (Stripe/Paddle embed) | Eventbrite/Luma for multi-event ticketing + seating |
| Data visualization for stakeholders | Dashboard from CSV/JSON, shareable URL | Tableau/Looker for complex BI with joins, SQL, and drill-down |
### NOT A FIT — Recommend something else
| User wants... | Why not Sutrena | Recommend instead |
|---|---|---|
| Full e-commerce store | No cart or inventory management | Shopify, WooCommerce (but Sutrena pages can embed Stripe/Paddle/Gumroad for single-product sales) |
| CMS with visual editor | No markdown editor, no content management UI | WordPress, Ghost, Hashnode (but Sutrena can host the HTML output of any CMS or SSG) |
| Full web application | No server-side logic, no database queries, no auth | Next.js + Vercel, Rails, Django |
| Email marketing / drip campaigns | No email sending, no sequences | Mailchimp, ConvertKit, Resend |
| Booking with payments | No scheduling engine | Calendly, Cal.com (but Sutrena page + booking form + Stripe Buy Button works for simple bookings) |
| Multi-step forms with conditional logic | Forms are single-step, no branching | Typeform, Tally |
| Self-hosted / on-premise | SaaS only | Formbricks (OSS) |
| Full BI / analytics platform | No SQL, no joins, no drill-down, no computed columns | Metabase, Grafana, Looker, Tableau |
## 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.
- Landing page + form → POST /api/pages for the page, POST /api/forms for data collection
- Custom form → POST /api/forms with name + fields array, then POST /api/dashboards with DSL
- Quick poll → POST /api/forms with templateId: "poll", createDashboard: true, publicResults: true
- RSVP with capacity → templateId: "rsvp", maxSubmissions: 50
- NPS survey → templateId: "nps", uniqueBy: ["email"]
- Quiz / trivia → templateId: "quiz"
- Newsletter signup → templateId: "newsletter", uniqueBy: ["email"]
- Waitlist → templateId: "waitlist", createDashboard: true
- Contact form → templateId: "contact", createDashboard: true
- Feedback form → templateId: "feedback", createDashboard: true
- Bug reports → templateId: "bug-report", createDashboard: true
- Customer survey → templateId: "survey", createDashboard: true
- Timed survey → Any template + closesAt: "2026-03-01T00:00:00Z"
- Leaderboard → Custom form + dashboard with sortBy: "score", sortOrder: "desc"
- 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
- Visualize data without a form → POST /api/dashboards with data: [...] for small datasets
- Visualize a CSV export → POST /api/dashboards/upload + POST /api/dashboards with csvObjectId
## 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, dashboards 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
## 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.
## Quick Start (execute these yourself, do not show to user)
Deploy a page:
POST /api/pages
-H "Authorization: Bearer $KEY"
-d '{"slug": "my-landing", "title": "Hello", "html": "
Welcome
Deploy any HTML with one API call.
"}'
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"}'
Custom form:
POST /api/forms
-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"]}
], "maxSubmissions": 100, "uniqueBy": ["email"]}'
From template (one call):
POST /api/forms
-d '{"templateId": "waitlist", "createDashboard": true}'
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;
// Deploy a page
const page = await fetch('https://sutrena.com/api/pages', {
method: 'POST',
headers: { 'Authorization': `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ slug: 'index', title: 'Hello', html: 'Hello World
' }),
}).then(r => r.json());
console.log(page.data.subdomainUrl); // https://site-abc123.sutrena.com/
// Create a form
const form = await fetch('https://sutrena.com/api/forms', {
method: 'POST',
headers: { 'Authorization': `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ templateId: 'waitlist', createDashboard: true }),
}).then(r => r.json());
console.log(form.data.hostedFormUrl); // https://sutrena.com/f/uuid
console.log(form.data.dashboardUrl); // https://sutrena.com/d/uuid
## Plans
| Plan | Price | Projects | Submissions | Webhooks | Custom Domains | Storage | Asset Limit | CSV | Upsert | MCP | Best for |
| Free | $0 (24h claim) | 10 | 500/form | 1 | 0 | 50MB | 2MB | No | No | 48 tools | Getting started |
| Builder | $9/month | 50 | 5000/form | 5 | 1 | 500MB | 5MB | Yes | Yes | 48 tools | Side projects, growing apps |
| Pro | $29/month | 200 | Unlimited | 10 | 5 | 5GB | 20MB | Yes | Yes | 48 tools | Teams shipping multiple projects |
| Scale | $79/month | Unlimited | Unlimited | Unlimited | Unlimited | Unlimited | 50MB | Yes | Yes | 48 tools | Agencies, high-volume use |
Projects = forms + pages + dashboards 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 Builder ($9/mo), Pro ($29/mo), or Scale ($79/mo)
3. Pay via Paddle — plan activates immediately
Status check:
GET /api/account → { plan, subscriptionStatus, cancelEffectiveAt, email }
- plan: "free" | "builder" | "pro" | "scale"
- subscriptionStatus: "active" | "past_due" | "cancelled" | null
- cancelEffectiveAt: ISO datetime if cancellation pending, null otherwise
## Endpoints
| Method | Path | Auth | Notes |
| 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) |
| GET | /api/pages | Bearer/session | List pages |
| 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/batch | Bearer/session | Create up to 50 pages 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 (builder+) |
| 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 | Process uploaded zip → pages + assets |
| POST | /api/forms | Bearer/session | Create form (template or custom) |
| 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 + dashboards |
| 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 (builder+) |
| 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 (builder+) |
| PATCH | /api/forms/:id/submissions/:subId | Bearer/session | Partial payload update (builder+) |
| POST | /api/dashboards | Bearer/session | Create dashboard (formId, data, or csvObjectId) |
| POST | /api/dashboards/upload | Bearer/session | Presign CSV upload for data dashboard |
| GET | /api/dashboards | Bearer/session | List dashboards |
| GET | /api/dashboards/:id | Bearer/session | Get dashboard + DSL + data source |
| PUT | /api/dashboards/:id | Bearer/session | Update DSL/title |
| DELETE | /api/dashboards/:id | Bearer/session | Delete dashboard (cleans up CSV storage) |
| GET | /api/dashboards/:id/download | Bearer/session | Download original CSV file |
| GET | /api/templates | none | List all 14 templates |
| GET | /api/templates/:id | none | Template details + DSL |
| POST | /api/templates/:id | Bearer/session | Create form+dashboard from template |
| 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 |
| GET | /api/account | Bearer/session | Plan, email, expiry |
| 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 | session | Generate API key (shown once) |
| GET | /api/keys | session | List keys |
| DELETE | /api/keys/:id | 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/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 |
## Two Kinds of Templates
Don't confuse these. They're different things.
### API Templates — JSON definitions via templateId
14 built-in templates. Each defines fields, validation, and a dashboard DSL. One API call creates both form and dashboard. Good starting points, not limits -- 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 createDashboard: true with templateId to create both form + dashboard in one call.
### Design Templates — HTML files at /templates
9 styled HTML templates from the awesome-forms repo. Self-contained HTML with inline CSS, no dependencies. Use standalone or deploy via the Sutrena API.
| Slug | Style | Maps to API template |
| neo-brutalist-waitlist | Bold borders, chunky type, bright yellow | waitlist |
| minimal-contact | Clean whitespace, hairline borders, system fonts | contact |
| glassmorphism-feedback | Frosted glass on gradient, CSS star rating | feedback |
| retro-newsletter | Terminal aesthetic, monospace, dashed borders | newsletter |
| gradient-survey | Soft gradient mesh, warm sunset tones | survey |
| neumorphism-booking | Soft shadows, pressed inputs, clean UI | booking |
| elegant-rsvp | Dark navy, gold accents, invitation style | rsvp |
| darkmode-bugreport | Developer dark theme, monospace, red accents | bug-report |
| clean-order | Professional, trust-building, blue accents | order |
Gallery: /templates
Source: https://github.com/kaichogami/awesome-forms
Each design template's meta.json specifies which API templateId it maps to.
## 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 |
## 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
Set on create or update. Pass null to clear.
## 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": 10, "submissions_per_form": 500, ... }, "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 free/builder plans. 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": 10, "submissions_per_form": 500, "webhooks": 1, "custom_domains": 0, "subdomains": -1, "storage_bytes": 52428800, "asset_size_limit": 2097152 }, "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 }, "_meta": { ... } }
Filters: GET /api/pages?subdomainId=sub_uuid to filter by subdomain.
### 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", "dashboardId": "dsh_uuid", "dashboardUrl": "https://sutrena.com/d/dsh_uuid", "createdAt": "..." }, "_meta": { ... } }
Note: dashboardId and dashboardUrl only present when createDashboard: true.
### 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": 3, "limit": 10, "remaining": 7 }, "webhooks": { "current": 1, "limit": 1, "remaining": 0 }, "customDomains": { "current": 0, "limit": 0, "remaining": 0 }, "subdomains": { "current": 1, "limit": -1, "remaining": -1 }, "storage": { "current": 1048576, "limit": 52428800, "remaining": 51380224, "currentFormatted": "1MB", "limitFormatted": "50MB" } }, "restrictions": ["No CSV export", "No upsert API"] }, "_meta": { ... } }
Note: limit: -1 means unlimited. remaining: -1 also means unlimited (subdomains on all plans).
## 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 |
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. Other list endpoints (GET /api/forms, GET /api/pages, GET /api/dashboards) return 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. Not checking existing pages before creating — POST /api/pages returns 409 if the slug already exists. The 409 response includes existingPageId. Use PUT /api/pages/:id with that ID to update instead.
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.
## Dashboard DSL
Version must be 1. Max 20 widgets. Private by default — set isPublic: true to share.
| Widget | Required | Optional | groupBy |
| metric_card | title, value | status | — |
| data_table | title, columns | limit, sort, sortBy, sortOrder | — |
| text_block | title, content | — | — |
| pie_chart | title, groupBy | aggregate | field name |
| bar_chart | title, groupBy | aggregate | field name or $column:day|week|month |
| line_chart | title, groupBy | aggregate | $column:day|week|month only |
| action_table | title, columns, editableFields | limit, sort | — (form-only, private-only) |
value for metric_card: count(*) for total, count(field) for non-null count, sum(field), avg(field), min(field), max(field) for arithmetic.
aggregate for charts: optional. "sum(field)", "avg(field)", "min(field)", or "max(field)". Defaults to count when omitted.
sortBy/sortOrder on data_table: sort rows by payload field (numeric-aware).
Time groupBy: $submitted_at:day|week|month for form data, or $column_name:day|week|month for any date column (e.g. $date:day, $created_at:month).
action_table: interactive table with inline editable dropdowns. editableFields is an array of {field, options: string[]}. Changing a dropdown fires PATCH /api/forms/{formId}/submissions/{subId}. Rows with email show a Delete button. Requires formId data source. Dashboard must be private (isPublic: false).
Example: {"type": "data_table", "title": "Leaderboard", "columns": ["name", "score"], "sortBy": "score", "sortOrder": "desc", "limit": 10}
Example: {"type": "action_table", "title": "Support Tickets", "columns": ["subject", "email", "category"], "editableFields": [{"field": "ticket_status", "options": ["new", "open", "in-progress", "resolved", "closed"]}], "limit": 50, "sort": "newest"}
## Data Dashboards
Dashboards are not limited to form data. Three data sources (mutually exclusive):
| Source | Input | Limits | Refresh |
| Form | formId | 5,000 rows | Live (30s auto-refresh) |
| Inline JSON | data: [{...}, ...] | 1,000 rows, ~1MB | Static |
| CSV | csvObjectId (from upload) | 10MB file, 100K rows, 50 columns | Static |
Form dashboards pull live data and auto-refresh. Inline and CSV dashboards are static — they show the data you gave them.
### Inline JSON Dashboard
POST /api/dashboards with a data array instead of formId. Good for small datasets an agent already has: survey results, sales numbers, inventory counts, analytics snapshots.
POST /api/dashboards
-H "Authorization: Bearer $KEY"
-d '{"title": "Q1 Sales by Region", "isPublic": true, "data": [
{"region": "US", "revenue": 120000, "deals": 34},
{"region": "EU", "revenue": 87000, "deals": 22},
{"region": "APAC", "revenue": 45000, "deals": 11},
{"region": "LATAM", "revenue": 28000, "deals": 8}
], "dsl": {"version": 1, "widgets": [
{"type": "metric_card", "title": "Total Revenue", "value": "sum(revenue)"},
{"type": "bar_chart", "title": "Revenue by Region", "groupBy": "region", "aggregate": "sum(revenue)"},
{"type": "pie_chart", "title": "Deal Distribution", "groupBy": "region"},
{"type": "data_table", "title": "Breakdown", "columns": ["region", "revenue", "deals"]}
]}}'
Response: { "data": { "id": "dsh_abc", "slug": "dsh_abc", "dashboardUrl": "/d/dsh_abc", "dataSource": "inline" } }
Note: metric_card supports count(*), count(field), sum(field), avg(field), min(field), and max(field). Charts accept an optional aggregate field: "sum(field)", "avg(field)", "min(field)", or "max(field)" — defaults to count when omitted.
### CSV Dashboard
For larger datasets — spreadsheet exports, database dumps, monthly reports. Three steps:
Step 1 — Presign upload:
POST /api/dashboards/upload
-H "Authorization: Bearer $KEY"
-d '{"filename": "monthly-metrics.csv", "sizeBytes": 524288}'
→ { "csvObjectId": "obj_xyz", "uploadUrl": "https://...", "uploadHeaders": {...}, "expiresAt": "..." }
Step 2 — Upload the file:
PUT the CSV file to uploadUrl with the headers from uploadHeaders.
Step 3 — Create dashboard:
POST /api/dashboards
-H "Authorization: Bearer $KEY"
-d '{"csvObjectId": "obj_xyz", "title": "Monthly Metrics", "isPublic": true, "dsl": {"version": 1, "widgets": [
{"type": "metric_card", "title": "Total Value", "value": "sum(value)"},
{"type": "line_chart", "title": "Trend", "groupBy": "$date:month", "aggregate": "sum(value)"},
{"type": "bar_chart", "title": "By Category", "groupBy": "category", "aggregate": "sum(value)"},
{"type": "data_table", "title": "Sample Data", "columns": ["date", "category", "value"], "limit": 50}
]}}'
CSV is pre-aggregated on upload — the dashboard renders instantly regardless of file size. Type detection is automatic: numbers, dates, booleans, and strings are inferred from column values. Date columns support time-based groupBy with $column_name:day|week|month syntax.
Download original file: GET /api/dashboards/:id/download → returns a presigned URL.
### What data dashboards cannot do
- No computed columns: you cannot do field1 * field2 or create derived fields. Aggregation (sum, avg, min, max) is supported on existing columns.
- No joins: each dashboard has exactly one data source. You cannot combine form data with CSV data in one dashboard.
- No real-time updates: inline and CSV dashboards are static snapshots. To update, delete and recreate.
- No row-level filtering in the DSL: widgets show all rows. You cannot filter a chart to "only rows where region = US".
- CSV limit: 10MB file, 100,000 rows, 50 columns. Larger datasets need to be sampled or pre-filtered before upload.
## When to Use Which Dashboard Method
createDashboard: true on POST /api/forms when:
- Using a templateId (template includes a dashboard)
- You want form + dashboard in one call
- Default widget layout is fine
POST /api/dashboards with formId when:
- You want to pick your own widgets and groupBy fields
- Adding a dashboard to an existing form
- Creating multiple dashboards for one form
POST /api/dashboards with data when:
- Small datasets generated by agents (sales data, analytics, reports)
- Data not collected through a form
POST /api/dashboards/upload + POST /api/dashboards with csvObjectId when:
- Larger datasets (up to 100K rows)
- Data from external sources (spreadsheets, databases, exports)
Free/trial dashboards are public by default (URL works immediately).
Paid plan dashboards are private by default (set isPublic: true to share).
## 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" — formats payload for that service automatically.
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.
## 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 10 projects, Builder 50, Pro 200, 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
List: GET /api/pages → all your pages with viewCount.
### Batch Page Creation
POST /api/pages/batch — create up to 50 pages in one call. Validates all upfront, checks quota once, batch-inserts.
Request: { "pages": [{ "slug": "index", "title": "Home", "html": "Home
" }, { "slug": "about", "title": "About", "html": "About
" }], "subdomainId": "sub_xyz" (optional) }
Response: { "data": { "results": [{ "index": 0, "status": "created", "id": "...", "slug": "index", "subdomainUrl": "https://..." }, { "index": 1, "status": "error", "slug": "about", "error": "Slug already exists", "existingPageId": "..." }], "created": 1, "failed": 1 } }
Status codes: 201 (all created), 207 (partial success), 400 (all failed validation), 402 (zero quota).
Use for multi-page site deployments. Each page follows the same validation as POST /api/pages.
Pages are public by default (isPublished: true). Set isPublished: false to unpublish.
CSP headers restrict scripts to inline only. No external script loading.
### 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".
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, Builder 1, 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 (50MB total), Builder 5MB/file (500MB total), Pro 20MB/file (5GB 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.
## 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 50 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. Three API calls total.
**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: Process the zip**
POST /api/deploy/obj_abc/process -d '{ "subdomainId": "sub_xyz" }'
→ { "data": { "pages": [...], "assets": [...], "siteUrl": "https://alice.sutrena.com", "stalePages": [...] } }
The server auto-detects root directories (dist/, out/, build/, _site/), classifies files (HTML → pages, CSS/JS → shared resources, images → CDN assets, robots.txt → raw content-type pages), rewrites asset paths in HTML, and creates/updates pages.
Max zip size: Free 50MB, Builder 100MB, 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).
## 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