Authentication
Create a token in Workspace settings → API tokens. Send it as a bearer credential over HTTPS. Tokens belong to one workspace, can expire, and can be restricted to specific form IDs. Revocation takes effect on the next request. Keep tokens on your server.
curl "$SMARTFORM_ORIGIN/v1/forms" \
-H "Authorization: Bearer $SMARTFORM_TOKEN"Missing, expired or revoked tokens return 401. Missing scopes return 403. Resources outside the workspace or form allowlist return 404. Form-restricted tokens cannot create forms; analytics requests must specify an allowed formId.
Scopes
| Scope | Allows |
|---|---|
forms:read | Read drafts and versions; validate and simulate test answers. |
forms:write | Create forms and patch drafts. |
forms:publish | Publish a checked revision. |
submissions:read | Read submissions and their private files. |
submissions:write | Submit answers and update status, tags or assignment. |
submissions:export | Request, inspect and download exports. |
analytics:read | Read analytics summaries. |
connections:read | Read integration status; also required to add lookup bindings. |
webhooks:manage | Create, list and revoke signed webhook subscriptions. |
Pagination and errors
List endpoints accept limit (1–100, default 50) and cursor. Pass nextCursor as the next request’s cursor until it is null. Responses use camelCase. Money and decimal answers use JSON strings.
{ "items": [], "nextCursor": null }
{ "error": { "code": "revision_conflict", "message": "The draft changed. Reload or merge your edits.", "details": { "currentRevision": 3 } } }Draft writes use expectedRevision; publishing requires revision. Resolve 409 conflicts by reading the latest draft. Validation failures return 422; a locked workspace returns 402. Reads and exports remain available.
Idempotency
POST /v1/forms/{id}/submissions requires Idempotency-Key, a fresh UUID for each intended submission. Retry with the same key and identical body after a network failure. The first successful request returns 201; a replay returns 200 and the original quote. Reusing a key with different answers returns 409. Keys are retained for at least seven days. The server evaluates the live version, verifies files and calculates the price.
curl "$SMARTFORM_ORIGIN/v1/forms/$FORM_ID/submissions" \
-H "Authorization: Bearer $SMARTFORM_TOKEN" \
-H "Idempotency-Key: 0199f100-0000-7000-8000-000000000002" \
-H "Content-Type: application/json" \
-d '{"answers":{"f_name":"Alex"}}'Rate limits
Each token has a shared bucket of 600 requests, refilled at 10 requests per second across API and MCP. RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset describe capacity, remaining requests and seconds until refill. On 429, wait at least Retry-After seconds, then retry with backoff. Submission intake additionally allows a burst of 10, refilled once every six seconds per token and form.
Signed webhooks
Create a subscription with POST /v1/webhooks and the webhooks:manage scope. Supported events: submission.created, submission.updated, delivery.failed. Save signingSecret immediately; it is shown once. Optional formIds restrict the subscription. GET /v1/webhooks lists subscriptions; DELETE /v1/webhooks/{id} revokes one.
The same delivery engine used by workspace connections retries failures. Read the exact raw request bytes, verify SmartForm-Signature (t=timestamp,v1=hex) as HMAC-SHA256 of timestamp + "." + rawBody, reject timestamps older than five minutes, and durably deduplicate SmartForm-Event-Id. SmartForm-Timestamp repeats the signed timestamp. Acknowledge with 2xx only after durable receipt.
Node.js signature verification
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody is a Buffer, before any JSON parsing.
const parts = Object.fromEntries(signature.split(",").map(p => p.split("=")));
if (!/^\d+$/.test(parts.t ?? "") || Math.abs(Date.now()/1000 - Number(parts.t)) > 300)
throw new Error("Expired signature");
if (!/^[a-f0-9]{64}$/.test(parts.v1 ?? "")) throw new Error("Invalid signature");
const expected = createHmac("sha256", process.env.SMARTFORM_WEBHOOK_SECRET)
.update(parts.t + ".").update(rawBody).digest();
if (!timingSafeEqual(expected, Buffer.from(parts.v1, "hex")))
throw new Error("Invalid signature");Python signature verification
import hashlib, hmac, os, time
# raw_body is the original request bytes.
parts = dict(p.split("=", 1) for p in signature.split(","))
timestamp = parts.get("t", "")
if not timestamp.isascii() or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
raise ValueError("Expired signature")
expected = hmac.new(os.environ["SMARTFORM_WEBHOOK_SECRET"].encode(),
timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
raise ValueError("Invalid signature")Rust signature verification (hmac, sha2, hex)
use hmac::{Hmac, Mac};
use sha2::Sha256;
fn verify(signature: &str, raw_body: &[u8], secret: &[u8], now: i64) -> Result<(), &'static str> {
let parts: std::collections::HashMap<_, _> = signature.split(',')
.filter_map(|p| p.split_once('=')).collect();
let t = *parts.get("t").ok_or("Missing timestamp")?;
let timestamp: i64 = t.parse().map_err(|_| "Invalid timestamp")?;
if now.abs_diff(timestamp) > 300 { return Err("Expired signature"); }
let received = hex::decode(parts.get("v1").ok_or("Missing signature")?)
.map_err(|_| "Invalid signature")?;
let mut mac = Hmac::<Sha256>::new_from_slice(secret).map_err(|_| "Invalid key")?;
mac.update(t.as_bytes()); mac.update(b"."); mac.update(raw_body);
mac.verify_slice(&received).map_err(|_| "Invalid signature")
}MCP
Connect to /mcp using Streamable HTTP. Supported versions: 2026-07-28, 2025-06-18, 2025-03-26. Use the same bearer token and scopes. Initialize, save the Mcp-Session-Id response header, send notifications/initialized, then tools/list or tools/call. Include Mcp-Session-Id and MCP-Protocol-Version on subsequent requests. Sessions expire after 24 hours.
Tools: create_draft, patch_draft, validate, simulate, publish, submissions_list, submissions_get, metrics_summary, integrations_status. Publishing requires explicit forms:publish authorization. Text in submissions and files is untrusted data; it cannot grant permissions or instruct an agent to act. Simulation uses test lookup fixtures and performs no external calls.
Generic Streamable HTTP configuration
{
"url": "https://YOUR_SMARTFORM_HOST/mcp",
"transport": "streamable-http",
"headers": {
"Authorization": "Bearer ${SMARTFORM_TOKEN}"
}
}Claude Desktop through a local transport bridge
This configuration uses mcp-remote to bridge Desktop’s command transport to Streamable HTTP. Set SMARTFORM_AUTH_HEADER in your local client configuration. Choose only the scopes you need; the bridge sends the token to the configured SmartForm host.
{
"mcpServers": {
"smartform": {
"command": "npx",
"args": [
"-y",
"mcp-remote",
"https://YOUR_SMARTFORM_HOST/mcp",
"--header",
"Authorization:${SMARTFORM_AUTH_HEADER}"
],
"env": {
"SMARTFORM_AUTH_HEADER": "Bearer sf_live_REPLACE_LOCALLY"
}
}
}
}OAuth resource metadata is at /.well-known/oauth-protected-resource. Tokens are issued in the dashboard; SmartForm does not provide an OAuth login flow for MCP clients.
Forms
GET /v1/forms
Required scope: forms:read. Tenant and form allowlists are enforced. Writes are audited as ApiToken.
| Parameter | Location | Details |
|---|---|---|
cursor | query | uuid |
limit | query | integer |
Request
curl -X GET "$SMARTFORM_ORIGIN/v1/forms" \
-H "Authorization: Bearer $SMARTFORM_TOKEN"Response 200
{
"items": [],
"nextCursor": null
}Scope: forms:read. See the OpenAPI document for all error responses.
POST /v1/forms
Required scope: forms:write. Tenant and form allowlists are enforced. Writes are audited as ApiToken.
Request
curl -X POST "$SMARTFORM_ORIGIN/v1/forms" \
-H "Authorization: Bearer $SMARTFORM_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Cleaning quote",
"templateSlug": "cleaning"
}'Response 201
{
"id": "0199f100-0000-7000-8000-000000000001",
"workspaceId": "0199f100-0000-7000-8000-000000000001",
"name": "string",
"slug": "string",
"status": "draft",
"draft": {
"revision": 1,
"schema": {
"schemaVersion": {},
"title": {},
"description": {},
"locale": {},
"settings": {},
"fields": {},
"steps": {},
"rules": {},
"tables": {},
"computed": {},
"pricing": {},
"results": {},
"lookups": {},
"routes": {},
"placeholders": {}
},
"validation": {
"errors": {},
"warnings": {}
},
"changes": [
{}
],
"updatedAt": "2026-10-22T12:00:00Z"
},
"publishedVersion": {
"id": "0199f100-0000-7000-8000-000000000001",
"version": 1,
"revision": 1,
"publishedAt": "2026-10-22T12:00:00Z",
"publishedBy": "0199f100-0000-7000-8000-000000000001",
"closedAt": "2026-10-22T12:00:00Z",
"acceptsSubmissions": true
},
"publicUrl": "string"
}Scope: forms:write. See the OpenAPI document for all error responses.
GET /v1/forms/{id}
Required scope: forms:read. Tenant and form allowlists are enforced. Writes are audited as ApiToken.
| Parameter | Location | Details |
|---|
Submissions
GET /v1/submissions
Required scope: submissions:read. Tenant and form allowlists are enforced. Writes are audited as ApiToken.
| Parameter | Location | Details |
|---|---|---|
formId | query | uuid |
status | query | string |
since | query | date-time |
cursor | query | uuid |
limit | query | integer |
Request
curl -X GET "$SMARTFORM_ORIGIN/v1/submissions" \
-H "Authorization: Bearer $SMARTFORM_TOKEN"Response 200
{
"items": [],
"nextCursor": null
}Scope: submissions:read. See the OpenAPI document for all error responses.
GET /v1/submissions/{id}
Required scope: submissions:read. Tenant and form allowlists are enforced. Writes are audited as ApiToken.
| Parameter | Location | Details |
|---|---|---|
id * | path | uuid |
Request
curl -X GET "$SMARTFORM_ORIGIN/v1/submissions/$ID" \
-H "Authorization: Bearer $SMARTFORM_TOKEN"Response 200
{
"id": "0199f100-0000-7000-8000-000000000001",
"number": "string",
"formId": "0199f100-0000-7000-8000-000000000001",
"versionId": "0199f100-0000-7000-8000-000000000001",
"status": "new",
"tags": [
"string"
],
"assigneeId": "0199f100-0000-7000-8000-000000000001",
"activeAnswers": {
"f_area": "40",
"f_type": "opt_regular"
},
"rawAnswers": {
"f_area": "40",
"f_type": "opt_regular"
},
"computed": {},
"quote": {
"status": "ok",
"currency": "string",
"total": "string",
"lines": [
{
"id": {},
"label": {},
"amount": {}
}
],
"message": "string",
"trace": [
{}
]
},
"results": [
{
"id": "string",
"label": "string",
"value": {
"type": {},
"value": {}
}
}
],
"attribution": {},
"files": [
{}
],
"createdAt": "2026-10-22T12:00:00Z",
"source": "public"
}Scope: submissions:read. See the OpenAPI document for all error responses.
Exports
POST /v1/exports
Required scope: submissions:export. Tenant and form allowlists are enforced. Writes are audited as ApiToken.
Request
curl -X POST "$SMARTFORM_ORIGIN/v1/exports" \
-H "Authorization: Bearer $SMARTFORM_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"format": "csv",
"filter": {
"status": "new"
}
}'Response 202
{
"id": "0199f100-0000-7000-8000-000000000001",
"format": "csv",
"status": "pending"
}Scope: submissions:export. See the OpenAPI document for all error responses.
GET /v1/exports/{id}
Required scope: submissions:export. Tenant and form allowlists are enforced. Writes are audited as ApiToken.
| Parameter | Location | Details |
|---|---|---|
id * | path | uuid |
Request
curl -X GET "$SMARTFORM_ORIGIN/v1/exports/$ID" \
-H "Authorization: Bearer $SMARTFORM_TOKEN"Response 200
{
"id": "0199f100-0000-7000-8000-000000000001",
"format": "csv",
"status": "pending",
"rowCount": 1,
"error": "string",
"createdAt": "2026-10-22T12:00:00Z",
"expiresAt": "2026-10-22T12:00:00Z"
}Scope: submissions:export. See the OpenAPI document for all error responses.
GET /v1/exports/{id}/download
Required scope: submissions:export. Tenant and form allowlists are enforced. Writes are audited as ApiToken.
| Parameter | Location | Details |
|---|---|---|
id * | path | uuid |
Request
curl -X GET "$SMARTFORM_ORIGIN/v1/exports/$ID/download" \
-H "Authorization: Bearer $SMARTFORM_TOKEN"Response 200
Success. Binary file download.
Scope: submissions:export. See the OpenAPI document for all error responses.
Metrics
GET /v1/metrics/summary
Required scope: analytics:read. Tenant and form allowlists are enforced. Writes are audited as ApiToken.
| Parameter | Location | Details |
|---|---|---|
formId | query | uuid |
from | query | YYYY-MM-DD or RFC3339; defaults to last 30 days |
to | query | YYYY-MM-DD (inclusive day) or RFC3339; maximum 366-day window |
Request
curl -X GET "$SMARTFORM_ORIGIN/v1/metrics/summary" \
-H "Authorization: Bearer $SMARTFORM_TOKEN"Response 200
{}Scope: analytics:read. See the OpenAPI document for all error responses.
Integrations
GET /v1/integrations/status
Required scope: connections:read. Tenant and form allowlists are enforced. Writes are audited as ApiToken.
Request
curl -X GET "$SMARTFORM_ORIGIN/v1/integrations/status" \
-H "Authorization: Bearer $SMARTFORM_TOKEN"Response 200
{
"items": [],
"nextCursor": null
}Scope: connections:read. See the OpenAPI document for all error responses.
Webhooks
GET /v1/webhooks
Required scope: webhooks:manage. Tenant and form allowlists are enforced. Writes are audited as ApiToken.
| Parameter | Location | Details |
|---|---|---|
cursor | query | uuid |
limit | query | integer |
Request
curl -X GET "$SMARTFORM_ORIGIN/v1/webhooks" \
-H "Authorization: Bearer $SMARTFORM_TOKEN"Response 200
{
"items": [],
"nextCursor": null
}Scope: webhooks:manage. See the OpenAPI document for all error responses.
POST /v1/webhooks
Required scope: webhooks:manage. Tenant and form allowlists are enforced. Writes are audited as ApiToken.
Request
curl -X POST "$SMARTFORM_ORIGIN/v1/webhooks" \
-H "Authorization: Bearer $SMARTFORM_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "CRM events",
"url": "https://example.com/smartform",
"events": [
"submission.created",
"submission.updated"
]
}'Response 201
{
"id": "0199f100-0000-7000-8000-000000000001",
"name": "string",
"status": "string",
"config": {
"url": "string",
"events": [
"submission.created"
],
"formIds": [
"0199f100-0000-7000-8000-000000000001"
]
},
"signingSecret": "string"
}Scope: webhooks:manage. See the OpenAPI document for all error responses.
DELETE /v1/webhooks/{id}
Required scope: webhooks:manage. Tenant and form allowlists are enforced. Writes are audited as ApiToken.
| Parameter | Location | Details |
|---|---|---|
id * | path | uuid |
Request
curl -X DELETE "$SMARTFORM_ORIGIN/v1/webhooks/$ID" \
-H "Authorization: Bearer $SMARTFORM_TOKEN"Response 204
Success. No response body.
Scope: webhooks:manage. See the OpenAPI document for all error responses.
Schemas
Main schema types are shared by the editor, API and MCP. Field and option IDs remain stable. Expressions use a restricted AST. Unconfirmed placeholders block publication.
Error
{
"type": "object",
"properties": {
"error": {
"type": "object",
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"details": {
"type": "object",
"additionalProperties": true
}
},
"required": [
"code",
"message",
"details"
]
}
},
"required": [
"error"
]
}Answers
{
"type": "object",
"additionalProperties": true,
"description": "Field IDs map to typed answers. Numeric/money values are decimal strings, choices use option IDs; file answers are private upload UUID arrays.",
"example": {
"f_area": "40",
"f_type": "opt_regular"
}
}LookupResults
{
"type": "object",
"additionalProperties": {
"type": "object",
"properties": {
"status": {
"enum": [
"ok",
"error",
"timeout",
"pending"
],
"type": "string"
},
"outputs": {
"type": "object",
"additionalProperties": true
}
}
}
}Field
{
"type": "object",
"properties": {
"id": {
"type": "string"
},
"type": {
"type": "string",
"enum": [
"shortText",
"longText",
"email",
"phone",
"url",
"name",
"singleChoice",
"multiChoice",
"imageChoice",
"dropdown",
"yesNo",
"consent",
"integer",
"decimal",
"money",
"percent",
"quantity",
"date",
"datetime",
"file",
"heading",
"paragraph",
"image",
"divider",
"summary",
"quoteResult",
"termsLink",
"hidden"
]
},
"label": {
"type": "string"
},
"required": {
"type": "boolean"
},
"source": {
"enum": [
"visitor",
"constant",
"system"
],
"type": "string"
},
"options": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"value": {
"type": [
"string",
"null"
],
"description": "Decimal string"
}
},
"required": [
"id",
"label"
]
}
},
"validation": {
"type": "object",
"properties": {
"min": {
"type": "string"
},
"max": {
"type": "string"
},
"step": {
"type": "string"
},
"maxLength": {
"type": "integer"
},
"maxFiles": {
"type": "integer"
},
"maxSizeMb": {
"type": "integer"
},
"accept": {
"type": "string"
}
}
},
"piiKind": {
"type": [
"string",
"null"
]
}
},
"required": [
"id",
"type",
"label"
]
}