These guides are primarily for agent readers. They explain how agents use Sails. If you are a person, start at sails.app or Add Sails.

Put a form on a site

A published page posts to a *.api.sh on the same mount. The handler reads the request, writes data, then redirects. There is no separate backend process.

This is not a platform webhook. A webhook starts a background goal. A form handler is short bash that must finish in one request.

Done looks like

The visitor submits. The browser lands on a thanks page (HTTP 303). A row exists in sql --db, or a file exists under the site. handler-test against the script returns status 303 without hitting the live URL.

Worked example: public newsletter subscribe

Job: anonymous visitors on a public site (--policy 644) can POST an email. You store one row per address. A second submit with the same email is a no-op.

Start from a site folder that already publishes (copy a template if you need one):

cp -r /templates/static-site-demo ./site

1. The form page

site/pages/newsletter/index.page.md:

---
title: Newsletter
excerpt: Subscribe.
draft: false
---
<form method="post" action="/newsletter/subscribe">
  <label>
    Email
    <input type="email" name="email" required>
  </label>
  <button type="submit">Subscribe</button>
</form>

action is a site path, not a full URL. The file that handles it is pages/newsletter/subscribe.api.shPOST /newsletter/subscribe.

Thanks page site/pages/newsletter/thanks.page.md can be a short “you’re in” note. The handler will respond redirect /newsletter/thanks.

2. The table

App rows go in Postgres, not a page glob and not sails.data.json.

createdb site/app.db
sql --db site/app.db --exec "
  CREATE TABLE IF NOT EXISTS subscribers (
    email_lower text PRIMARY KEY,
    email text NOT NULL,
    created_at timestamptz NOT NULL DEFAULT now()
  )
"

--exec prints { "affected": 0 } for DDL (or similar). email_lower is the unique key.

See Keep app data in a database.

3. The handler

site/pages/newsletter/subscribe.api.sh:

---
methods: [POST]
security: definer
redirect: /newsletter/thanks
body:
  type: object
  required: [email]
  properties:
    email: { type: string, minLength: 3 }
---
#!/usr/bin/env bash
set -euo pipefail

email="$(request param email)"
email_lower="$(printf '%s' "$email" | tr '[:upper:]' '[:lower:]')"

sql --db app.db --exec \
  "INSERT INTO subscribers (email_lower, email) VALUES (?, ?) ON CONFLICT (email_lower) DO NOTHING" \
  "$email_lower" "$email"

respond redirect /newsletter/thanks

--db app.db is relative to the published mount root (site/ if you published --path site). Put the bundle next to sails.site.json, or pass the same path you use in the workspace.

security: definer runs as the publisher, so anonymous visitors can write. Keep the script narrow. Only repository owners may create or edit *.api.sh files that declare security: definer.

If the visitor is signed in and should write as themselves, omit security: (default invoker) and set auth: required.

Invalid body: never reaches the script (400). Trust required / minLength / format for those fields. Still bind SQL with ?. Never eval the email.

4. Dry-run, then publish

check --path site
handler-test site/pages/newsletter/subscribe.api.sh \
  --method POST --form 'email=ada@example.com'

handler-test talks to the session workspace, not the live URL. --auth only toggles signed_in; you cannot forge sub. Definer dry-run still uses your VFS identity — it does not become the publisher.

publish create newsletter --path site --policy 644

--policy 644 is public. Default 600 is owner-only, so strangers cannot POST. Publish once; autosave redeploys. See Publish a site.

Then POST for real:

curl -s -D - -o /dev/null \
  -X POST "https://{id}.sails.app/newsletter/subscribe" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d 'email=ada@example.com'

Expect 303 and Location: /newsletter/thanks (same mount). Confirm the row:

sql --db site/app.db "SELECT email, email_lower FROM subscribers WHERE email_lower = ?" "ada@example.com"

Shape: { "rows": [{ "email": "ada@example.com", "email_lower": "ada@example.com" }], "columns": ["email", "email_lower"] }.

Flags and builtins you actually use

check help
handler-test help
PieceWhat it does
methods: [POST]Default is POST-only. Add GET only if the handler should run on navigation.
body: / query:JSON Schema. Invalid requests never enter the script.
security: definerVFS and sql --db run as the publisher. Public anonymous writes.
auth: requiredVisitor must be signed in. Does not elevate VFS by itself.
request param FIELDForm → JSON → query (first non-empty). Prefer this for HTML forms.
request jsonParsed JSON body (typed). Use for API clients, not application/x-www-form-urlencoded.
respond redirect /pathPrimary success. Default 303. Same-mount paths only.
SAILS_SITE_ROOTRepo prefix of this mount (site when --path site; empty at repo root). Prefix file writes: ${SAILS_SITE_ROOT:+$SAILS_SITE_ROOT/}.
check --path siteFront matter + MiniJinja + handler types. JSON report; look at "ok".
handler-test FILE --method POST --form 'k=v'Dry-run. Also --json '{…}'.

Do not use page sql: or sails.data.json for subscribers. Collections list pages at compile time. See Collections.

Failure modes

What you seeWhat it meansWhat to do
POST 404No *.api.sh for that path, or it is not on this mountFile is pages/newsletter/subscribe.api.sh for /newsletter/subscribe. Publish --path must include it.
405Method not in methodsDefault is POST. HTML forms are POST.
400 before your scriptbody: / query: rejected the payloadFix the form name= to match schema properties.
Write denied / empty table on a 644 siteDefault invoker as anonymous “other”Public writes need security: definer (owner-only to author).
Cannot save the handlerCollaborator tried to set security: definerAn owner writes that file.
sql without --dbFlag is requiredsql --db app.db … with the bundle next to the site root.
Unique violation on second submitYou used plain INSERTON CONFLICT (email_lower) DO NOTHING.
handler-test 200 + HTMLYou hit a page, not the handlerPath must end in .api.sh.
respond redirect to another hostSame-mount onlyrespond redirect-external https://… if you truly need off-site.
Form works for you, 403 for strangersMount policy 600 / 640publish create … --policy 644 for a public form.
You INSERTed into a globCollections are not a databasesql --db.

See Static sites for pages vs handlers, and Mail from an agent if the handler should notify someone.