Skip to main content
SiteShi p

Reference

Forms

Form configs are typed by the engine; the hosted runtime accepts submissions at /api/forms/{id}, validates them, blocks honeypot spam, and stores them in a per-site Durable Object with SQLite. Local preview proxies submissions to the live runtime, so you can test forms end-to-end during dev.

Forms post to /api/forms/{id}. The hosted runtime handles validation, honeypot spam protection, and stores submissions in a per-site Durable Object with built-in SQLite — queryable from your dashboard or the SiteShip API. You only write the HTML and the form config.

1. Create form config — data/forms/{id}.json

fields MUST be an object (not an array). Keys are field names, values define type and validation.

{
  "name": "Contact Form",
  "honeypot": "website",
  "fields": {
    "name": { "type": "text", "required": true },
    "email": { "type": "email", "required": true },
    "message": { "type": "text", "required": false }
  }
}

FormConfig reference

Field Type Required Notes
name string yes Display name of the form.
fields record yes Field definitions keyed by field name. Must be an object — never an array.
honeypot string no Name of a hidden field used for bot detection. Submissions with this field filled are silently dropped.
notify string no Email address to notify on new submissions.
successRedirect string no URL to redirect to on successful (non-AJAX) submission.
integration object no Named integration config (e.g. { type: "mailchimp", listId: "…" }).
webhook object no POST submissions to an external URL as JSON.

2. Write the HTML form (AJAX — REQUIRED, not optional)

Every <form action="/api/forms/<id>"> MUST be wrapped in Alpine.js AJAX. This is enforced by the author.form_missing_ajax contract rule — a bare <form> posting to /api/forms/<id> will fail the lint and break the user experience.

<form x-data="{ sending: false, sent: false, error: '' }" @submit.prevent="
  sending = true; error = '';
  fetch($el.action, { method: 'POST', body: new FormData($el) })
    .then(r => r.json())
    .then(d => { if (d.success) { sent = true; $el.reset(); } else { error = d.errors?.join(', ') || 'Something went wrong'; } })
    .catch(() => { error = 'Something went wrong'; })
    .finally(() => { sending = false; })
" method="POST" action="/api/forms/{id}">

  <!-- Success message (hidden until submitted) -->
  <div x-show="sent" x-cloak class="text-green-600 mb-4">Thank you! We'll be in touch.</div>

  <!-- Error message (hidden until error) -->
  <div x-show="error" x-cloak class="text-red-600 mb-4" x-text="error"></div>

  <!-- Form fields (hidden after success) -->
  <div x-show="!sent">
    <input type="text" name="name" required>
    <input type="email" name="email" required>
    <textarea name="message"></textarea>

    <!-- Honeypot: hidden from users, catches bots -->
    <input type="text" name="website" tabindex="-1" autocomplete="off" class="hidden" style="display:none">

    <button type="submit" :disabled="sending" x-text="sending ? 'Sending...' : 'Send'">Send</button>
  </div>
</form>

Field name attributes MUST match the keys in fields.

Why this matters — the JSON response trap

/api/forms/{id} is an API endpoint, not a web page. Without @submit.prevent, the browser performs a standard form POST and navigates to the endpoint URL. The user lands on a screen showing literal JSON:

{"success":true,"id":"59b7e475-f281-404e-95ee-dba920cba14a"}

The submission was saved, the webhook did fire, the notify email was sent — but the user thinks the site is broken. With AJAX the user stays on the page and sees the inline success / error state instead.

Pre-flight checklist for every form

  • <form> tag has x-data="{ sending: false, sent: false, error: '' }"
  • <form> tag has @submit.prevent="…fetch($el.action…)…"
  • <form> tag has method="POST" and action="/api/forms/<id>"
  • Success <div> uses x-show="sent" + x-cloak
  • Error <div> uses x-show="error" + x-cloak + x-text="error"
  • Field wrapper uses x-show="!sent" so inputs hide after success
  • Honeypot input is present, hidden via display:none
  • Submit button has :disabled="sending" and x-text for loading state
  • HTML field name= attributes exactly match fields keys in the config

Sending submissions to Zapier / Make / n8n (webhook)

Add a webhook block to the form config. The Worker POSTs { formName, data: { ...fields }, submittedAt } as JSON to the URL after storing the submission. No credentials needed — Zapier/Make webhook URLs contain their own auth token.

{
  "name": "Contact Form",
  "honeypot": "website",
  "fields": {
    "name": { "type": "text", "required": true },
    "email": { "type": "email", "required": true },
    "message": { "type": "text", "required": false }
  },
  "webhook": {
    "url": "https://hooks.zapier.com/hooks/catch/abc/xyz/"
  }
}

Optional webhook fields: method (default "POST") and headers (for custom auth tokens on non-Zapier endpoints).

The webhook fires in the background — the user gets their success response immediately. If the webhook fails, the submission is still stored safely.

Connecting to Mailchimp (email list subscribe)

Add an integration block. The user must have connected Mailchimp in the builder settings first — the Worker fetches the API key from the encrypted credentials store automatically.

{
  "name": "Newsletter Signup",
  "honeypot": "website",
  "fields": {
    "email": { "type": "email", "required": true },
    "name": { "type": "text", "required": false }
  },
  "integration": {
    "type": "mailchimp",
    "listId": "abc123"
  }
}

The listId is the Mailchimp Audience ID (found in Mailchimp → Audience → Settings → Audience name and defaults).

You can combine webhook and integration in the same form config — both fire after submission.

Not supported: file uploads

SiteShip forms do not support file uploads. Submissions are stored as strings only — any File object in a multipart/form-data submission is silently dropped. Never write <input type="file">. If the user needs file collection, point them at a hosted form builder (Tally, JotForm, Typeform) and embed its form via an absolute action URL, or replace the upload with a link/email so the user sends the file out-of-band.

Rules

  • Form ID should be descriptive: contact, newsletter, quote-request
  • Always include a honeypot field for spam protection
  • Always use AJAX submission (Alpine.js @submit.prevent + fetch) — never use redirect-based submission
  • Never use successRedirect in form config — AJAX handles success/error inline
  • Field names in HTML must exactly match keys in the config fields object
  • Style forms to match the site's design using Tailwind classes
  • Success/error messages MUST be inside the <form> tag (inside the x-data scope) — Alpine state like sent and error is only available within the element that declares x-data
  • Always add x-cloak to success/error divs so they are hidden on page load before Alpine initializes
  • Hide the form fields after success with x-show="!sent" on a wrapper div
  • Reset the form after successful submission with $el.reset()

Never write demo / placeholder forms

Every <form> you author must be wired to a real submission endpoint — either SiteShip's /api/forms/<id> (with a matching data/forms/<id>.json) or an absolute URL to a third-party service (Mailchimp, Formspree, etc.). If a real backend isn't appropriate for the section, render a plain CTA button or a mailto: link instead — never a non-functional <form> shell.

The contract validator flags these on every page/template edit:

  • author.form_action_missing<form> with no action, action="", or action="#". Submissions go nowhere.
  • author.form_action_invalidaction="javascript:...", or any local path that isn't /api/forms/<id> (with method="post"). Looks plausible, doesn't work.
  • author.form_missing_ajax<form action="/api/forms/<id>"> without @submit.prevent (or x-on:submit / onsubmit). Without an AJAX wrapper the browser navigates to the API endpoint after submit and shows raw JSON to the user.
  • author.form_field_unconfigured<input name="X"> (or <select>, <textarea>) where X isn't declared in the form config's fields and isn't the honeypot. The Worker silently drops these values.
  • author.form_field_missing_input — Form config declares a field that has no matching <input name="..."> in the HTML. Required fields are errors (users can never fill them); optional fields are warnings.
  • author.form_field_type_mismatch — Config field type (e.g. email) doesn't match the HTML input type (e.g. text). Warning — the form works but the user misses browser validation and mobile keyboard hints. Always set <input type="email"> for email fields, type="tel" for phone, etc.
  • author.form_fields_without_form — Named form fields (<input name>, <select name>, <textarea name>) found outside any <form> element. Without <form>, native submit fails, browser autofill is unreliable, and screen readers miss the form landmark. Never put form fields inside a plain <div x-data> — always use <form x-data> as the outer wrapper.
  • author.form_file_input_unsupported<input type="file"> anywhere in a page or template. The Worker stores strings only and silently drops File values.

External actions (https://..., mailto:, tel:) and method="get" search forms with non-SiteShip actions are intentionally not flagged.

Found something out of date? Open an issue. ← All docs