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:
curl https://api.sitesmith.cloud/v1/forms/me \
-H "Authorization: Bearer ssf_your_key_here"Keys come in two scopes:
| Scope | What it can do |
|---|---|
read | GET requests only. Anything else returns 403. |
write | Everything, including creating forms and deleting responses. |
A missing, revoked or mistyped key returns 401. Revoking a key in Workspace stops it right away.
Errors#
Errors return a JSON body with a message, and field messages when answers fail validation:
{ "error": "Some answers need another look", "fieldErrors": { "email": "Enter an email address, like [email protected]" } }| Status | Meaning |
|---|---|
400 | The request is malformed (bad JSON, unknown status, a limit outside 1 to 200). |
401 | No key, or the key is wrong or revoked. |
402 | Your plan's limit is reached (for example, the number of forms). |
403 | Not 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. |
404 | The form or response doesn't exist, or belongs to another account. |
413 | The body is over 256 KB. |
422 | The answers failed validation. See fieldErrors, keyed by field id. |
429 | Too 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.
{
"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
_gotchaand leave it empty. Responses with it filled in are accepted but not saved. - Allowed sites: if the form lists allowed sites, the browser's
Originmust be one of them, or the request returns403.
How the reply depends on the request:
| Request | Success | Failure |
|---|---|---|
JSON body, or Accept: application/json | 201 with { "id", "successMessage", "redirectUrl" } | The error status with { "error", "fieldErrors" } |
| Plain HTML form post | 303 to the form's redirect URL, or to the hosted thank-you page | 303 back to the hosted form with ?error=1 |
With fetch:
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);
}With plain HTML and no JavaScript:
<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.
| Method | Path | What it does |
|---|---|---|
GET | /me | Your account, the key's scope, your plan and this month's usage |
GET | /field-types | Every field type: type, label, hasOptions, layout |
GET | /templates | Starting templates: id, name, description, fields |
GET | /forms?q= | Your forms, most recently changed first. q filters by name. |
POST | /forms | Create 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}/duplicate | Copy a form (as a draft). Returns 201. |
GET | /forms/{id}/responses | Responses, newest first, in pages |
POST | /forms/{id}/responses | Submit 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.csv | Every response except spam, as a CSV download |
Account and usage#
curl https://api.sitesmith.cloud/v1/forms/me -H "Authorization: Bearer $SITESMITH_FORMS_KEY"{
"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.
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:
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#
{
"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
| Parameter | Values |
|---|---|
status | all (default, leaves out spam), new, read, spam or starred |
q | Text to search for in the answers |
since | Only responses on or after this date |
before | The next value from the previous page |
limit | 1 to 200, default 50 |
{
"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:
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.
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#
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#
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:
{ "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.createdX-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):
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
$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:
<div data-sitesmith-form="a1B2c3D4e5F6"></div>
<script src="https://workspace.sitesmith.cloud/forms/embed.js" async></script>Optional attributes on the div:
| Attribute | Effect |
|---|---|
data-theme="light" or data-theme="dark" | Use this color scheme instead of the form's setting |
data-hide-title | Hide the form's name and description |
When someone submits, the div fires a sitesmith-form:submitted event:
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:
<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.



