Search the docs

Type to find a guide or an answer, then use the arrow keys and Enter to open it.

Forms API

Read and manage SiteSmith Forms from your own code, take responses from any website, and verify webhooks.

Overview#

The Forms API lets you read and manage your SiteSmith Forms from your own code, and lets any website send responses to a published form.

  • Base URL: https://api.sitesmith.cloud/v1/forms
  • Bodies are JSON (Content-Type: application/json) unless noted.
  • Dates are ISO 8601 strings in UTC, like 2026-09-29T10:00:00.000Z.
  • Interactive reference, where you can try requests: api.sitesmith.cloud/docs. The OpenAPI 3.1 spec, for code generators and API clients, is at https://api.sitesmith.cloud/openapi.json.

There are two kinds of endpoint:

  • Public endpoints under /public/. No key. Any website can call them from the browser. Use these to show a form or to post answers from your own HTML.
  • Keyed endpoints (everything else). They need an API key and reach only your account's forms.

Authentication#

Create a key in Workspace › Forms › Settings › API keys. The key is shown once, so copy it somewhere safe. Send it in the Authorization header:

bash
curl https://api.sitesmith.cloud/v1/forms/me \
  -H "Authorization: Bearer ssf_your_key_here"

Keys come in two scopes:

ScopeWhat it can do
readGET requests only. Anything else returns 403.
writeEverything, including creating forms and deleting responses.

A missing, revoked or mistyped key returns 401. Revoking a key in Workspace stops it right away.

Warning

Keep write keys on your server. Browser requests with a key are allowed (CORS is open to any origin, without cookies), but anyone who can see your page's code can then copy the key.

Errors#

Errors return a JSON body with a message, and field messages when answers fail validation:

json
{ "error": "Some answers need another look", "fieldErrors": { "email": "Enter an email address, like [email protected]" } }
StatusMeaning
400The request is malformed (bad JSON, unknown status, a limit outside 1 to 200).
401No key, or the key is wrong or revoked.
402Your plan's limit is reached (for example, the number of forms).
403Not allowed: a read key tried to write, the form is closed, the site isn't in the form's allowed sites, or the month's responses are used up.
404The form or response doesn't exist, or belongs to another account.
413The body is over 256 KB.
422The answers failed validation. See fieldErrors, keyed by field id.
429Too many requests. Wait the number of seconds in the Retry-After header.

Rate limits#

Each key can make 120 requests a minute. Public submissions are limited to 10 responses a minute per form from one IP address.

Public endpoints#

These need no key and send Access-Control-Allow-Origin: *, so any website can call them. They never use cookies.

Get a form#

GET /public/{publicId}

Returns what a visitor needs to show the form. Draft forms return 404.

json
{
  "publicId": "a1B2c3D4e5F6",
  "name": "Contact",
  "description": "",
  "fields": [{ "id": "email", "type": "email", "label": "Email", "required": true }],
  "settings": { "submitLabel": "Submit", "successMessage": "Thanks, we got your response.", "redirectUrl": "", "theme": "auto", "accent": "#7c3aed" },
  "closed": null,
  "branding": true
}

closed is null while the form takes responses, or a sentence explaining why it doesn't.

Submit a response#

POST /public/{publicId}/responses

Send answers keyed by field id. The body can be JSON, application/x-www-form-urlencoded or multipart/form-data (text fields only; files are ignored).

  • Checkboxes: a JSON array, or in a form post, repeated keys (topics=Sales&topics=Support), topics[] keys, or one comma-separated value.
  • Spam trap: include a hidden text input named _gotcha and leave it empty. Responses with it filled in are accepted but not saved.
  • Allowed sites: if the form lists allowed sites, the browser's Origin must be one of them, or the request returns 403.

How the reply depends on the request:

RequestSuccessFailure
JSON body, or Accept: application/json201 with { "id", "successMessage", "redirectUrl" }The error status with { "error", "fieldErrors" }
Plain HTML form post303 to the form's redirect URL, or to the hosted thank-you page303 back to the hosted form with ?error=1

With fetch:

javascript
const res = await fetch("https://api.sitesmith.cloud/v1/forms/public/a1B2c3D4e5F6/responses", {
  method: "POST",
  headers: { "Content-Type": "application/json", Accept: "application/json" },
  body: JSON.stringify({ name: "Ada Lovelace", email: "[email protected]", topics: ["Sales"] }),
});
const body = await res.json();
if (res.status === 201) {
  console.log(body.successMessage);
} else {
  console.log(body.error, body.fieldErrors);
}

Tip

A body sent as text/plain is read as JSON too. That skips the browser's CORS preflight request.

With plain HTML and no JavaScript:

html
<form action="https://api.sitesmith.cloud/v1/forms/public/a1B2c3D4e5F6/responses" method="post">
  <input name="name" required>
  <input name="email" type="email" required>
  <label><input type="checkbox" name="topics" value="Sales"> Sales</label>
  <label><input type="checkbox" name="topics" value="Support"> Support</label>
  <input name="_gotcha" style="display:none" tabindex="-1" autocomplete="off">
  <button>Send</button>
</form>

Keyed endpoints#

All paths are relative to https://api.sitesmith.cloud/v1/forms.

MethodPathWhat it does
GET/meYour account, the key's scope, your plan and this month's usage
GET/field-typesEvery field type: type, label, hasOptions, layout
GET/templatesStarting templates: id, name, description, fields
GET/forms?q=Your forms, most recently changed first. q filters by name.
POST/formsCreate a form. Returns 201 and the form.
GET/forms/{id}One form
PATCH/forms/{id}Change name, description, fields, settings or status
DELETE/forms/{id}Delete the form and its responses. Returns 204.
POST/forms/{id}/duplicateCopy a form (as a draft). Returns 201.
GET/forms/{id}/responsesResponses, newest first, in pages
POST/forms/{id}/responsesSubmit a response as your account
GET/forms/{id}/responses/{rid}One response
PATCH/forms/{id}/responses/{rid}Mark read, new or spam, or star it. Returns 204.
DELETE/forms/{id}/responses/{rid}Delete a response. Returns 204.
GET/forms/{id}/responses.csvEvery response except spam, as a CSV download

Account and usage#

bash
curl https://api.sitesmith.cloud/v1/forms/me -H "Authorization: Bearer $SITESMITH_FORMS_KEY"
json
{
  "account": { "id": "ACME", "name": "Acme Plumbing" },
  "key": { "id": "…", "scope": "write" },
  "plan": { "tier": "free", "limits": { "forms": 3, "submissionsPerMonth": 100, "apiKeys": 1, "branding": true } },
  "usage": { "forms": 2, "responsesThisMonth": 41 }
}

Create a form#

Send any of name, description, template (an id from /templates), fields, settings and status (draft, published or closed). New forms are drafts unless you say otherwise. A form needs at least one question before it can be published.

bash
curl https://api.sitesmith.cloud/v1/forms/forms \
  -H "Authorization: Bearer $SITESMITH_FORMS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Contact",
    "status": "published",
    "fields": [
      { "id": "name", "type": "text", "label": "Your name", "required": true },
      { "id": "email", "type": "email", "label": "Email", "required": true },
      { "id": "topics", "type": "checkboxes", "label": "Topics", "options": ["Sales", "Support"] }
    ],
    "settings": { "notifyEmails": ["[email protected]"], "allowedOrigins": ["https://example.com"] }
  }'

The same request with fetch from Node:

javascript
const res = await fetch("https://api.sitesmith.cloud/v1/forms/forms", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.SITESMITH_FORMS_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ template: "contact", status: "published" }),
});
if (!res.ok) throw new Error((await res.json()).error);
const form = await res.json();
console.log(form.hostedUrl, form.embed.script);

The form object#

json
{
  "id": "AB12CD34EF",
  "name": "Contact",
  "description": "",
  "publicId": "a1B2c3D4e5F6",
  "status": "published",
  "fields": [{ "id": "email", "type": "email", "label": "Email", "required": true }],
  "settings": {
    "submitLabel": "Submit",
    "successMessage": "Thanks, we got your response.",
    "redirectUrl": "",
    "notifyEmails": [],
    "allowedOrigins": [],
    "maxResponses": 0,
    "closeAt": "",
    "webhookUrl": "",
    "theme": "auto",
    "accent": "#7c3aed"
  },
  "responseCount": 12,
  "lastResponseAt": "2026-09-29T10:00:00.000Z",
  "createdAt": "2026-09-01T09:00:00.000Z",
  "updatedAt": "2026-09-28T16:20:00.000Z",
  "hostedUrl": "https://workspace.sitesmith.cloud/f/a1B2c3D4e5F6",
  "embed": { "script": "<div data-sitesmith-form=…", "iframe": "<iframe src=…" }
}

GET /forms returns { "data": [...] } where each item has the same fields except fields, settings and embed, plus fieldCount and unread (responses not yet read).

The webhook secret is never returned by the API. You can see it and change it in Workspace.

Fields. Each field has an id (lowercase letters, digits and underscores; answers use it as their key), a type, and a label. Optional: help, placeholder, required, options (for select, radio and checkboxes), min and max (numbers, text length, or the top score for rating), defaultValue and width (full or half). Leave out id and one is made from the label. heading, paragraph and pagebreak only lay out the form and never have answers.

Settings. redirectUrl sends people to your page after they submit. notifyEmails get a copy of each response. allowedOrigins limits which sites can submit from the browser (empty means any). maxResponses and closeAt stop the form automatically. webhookUrl must start with https://.

List responses#

GET /forms/{id}/responses

ParameterValues
statusall (default, leaves out spam), new, read, spam or starred
qText to search for in the answers
sinceOnly responses on or after this date
beforeThe next value from the previous page
limit1 to 200, default 50
json
{
  "data": [
    {
      "id": "RS12AB34CD",
      "form": { "id": "AB12CD34EF", "publicId": "a1B2c3D4e5F6", "name": "Contact" },
      "createdAt": "2026-09-29T10:00:00.000Z",
      "status": "new",
      "starred": false,
      "source": "embed",
      "origin": "https://example.com",
      "answers": { "name": "Ada Lovelace", "email": "[email protected]", "topics": ["Sales"] }
    }
  ],
  "next": "RS12AB34CD"
}

source is hosted (the SiteSmith form page), embed (a website), api or wordpress. next is null on the last page.

Reading every page in JavaScript:

javascript
async function allResponses(formId) {
  const all = [];
  let before = "";
  do {
    const url = new URL(`https://api.sitesmith.cloud/v1/forms/forms/${formId}/responses`);
    url.searchParams.set("limit", "200");
    if (before) url.searchParams.set("before", before);
    const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.SITESMITH_FORMS_KEY}` } });
    const page = await res.json();
    all.push(...page.data);
    before = page.next;
  } while (before);
  return all;
}

Submit as your account#

POST /forms/{id}/responses with a write key takes the same answers as the public endpoint. It skips the allowed-sites check, and the response is labeled api. The WordPress plugin sends the header X-SiteSmith-Source: wordpress so its responses are labeled wordpress.

bash
curl https://api.sitesmith.cloud/v1/forms/forms/AB12CD34EF/responses \
  -H "Authorization: Bearer $SITESMITH_FORMS_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Ada Lovelace", "email": "[email protected]" }'

Update or delete a response#

bash
curl -X PATCH https://api.sitesmith.cloud/v1/forms/forms/AB12CD34EF/responses/RS12AB34CD \
  -H "Authorization: Bearer $SITESMITH_FORMS_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "status": "read", "starred": true }'

curl -X DELETE https://api.sitesmith.cloud/v1/forms/forms/AB12CD34EF/responses/RS12AB34CD \
  -H "Authorization: Bearer $SITESMITH_FORMS_KEY"

Export as CSV#

bash
curl -o responses.csv https://api.sitesmith.cloud/v1/forms/forms/AB12CD34EF/responses.csv \
  -H "Authorization: Bearer $SITESMITH_FORMS_KEY"

One column per question, oldest response first, spam left out.

Webhooks#

Set a form's webhookUrl (it must be https:// and reachable on the public internet) and each new response is posted to it:

json
{ "event": "response.created", "response": { "id": "RS12AB34CD", "form": { "id": "AB12CD34EF", "publicId": "a1B2c3D4e5F6", "name": "Contact" }, "answers": { "email": "[email protected]" } } }

The response object is the same as in the API. Each request has these headers:

  • X-SiteSmith-Event: response.created
  • X-SiteSmith-Signature: sha256=<hex>, an HMAC-SHA256 of the raw request body using the form's webhook secret

Delivery is tried once and gives up after 10 seconds, so reply quickly and do slow work afterward. Check the signature against the raw body before you parse it.

In Node (Express):

javascript
import crypto from "node:crypto";
import express from "express";

const app = express();

app.post("/sitesmith-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const expected = "sha256=" + crypto.createHmac("sha256", process.env.SITESMITH_WEBHOOK_SECRET).update(req.body).digest("hex");
  const given = req.get("X-SiteSmith-Signature") ?? "";
  if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.status(401).end();
  }
  const { event, response } = JSON.parse(req.body.toString("utf8"));
  console.log(event, response.answers);
  res.status(204).end();
});

In PHP:

php
<?php
$body = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $body, getenv('SITESMITH_WEBHOOK_SECRET'));
$given = $_SERVER['HTTP_X_SITESMITH_SIGNATURE'] ?? '';

if (!hash_equals($expected, $given)) {
    http_response_code(401);
    exit;
}

$payload = json_decode($body, true);
error_log($payload['event'] . ': ' . json_encode($payload['response']['answers']));
http_response_code(204);

Embedding a form#

Each form object includes hostedUrl and ready-to-paste embed code.

Script embed. Resizes itself to fit the form:

html
<div data-sitesmith-form="a1B2c3D4e5F6"></div>
<script src="https://workspace.sitesmith.cloud/forms/embed.js" async></script>

Optional attributes on the div:

AttributeEffect
data-theme="light" or data-theme="dark"Use this color scheme instead of the form's setting
data-hide-titleHide the form's name and description

When someone submits, the div fires a sitesmith-form:submitted event:

javascript
document.querySelector("[data-sitesmith-form]").addEventListener("sitesmith-form:submitted", (e) => {
  console.log("Response saved", e.detail.id);
});

Plain iframe. A fixed height, with no script:

html
<iframe src="https://workspace.sitesmith.cloud/f/a1B2c3D4e5F6?embed=1" title="Contact" width="100%" height="600" style="border:0;max-width:720px" loading="lazy"></iframe>

Your own markup. Build the form yourself and post to the public submit endpoint, as shown in Submit a response.

WordPress. The SiteSmith Forms plugin connects with an API key and adds forms to pages with a block or the [sitesmith_form] shortcode.

Still stuck?

A real person will take it from here.