Getting started
Create a key in Settings → API & webhooks. It is shown once, so put it straight into your server’s environment variables — there is no way to look it up again, only to rotate it. Keys are available on Pro and Business.
Then confirm it works. /v1/me is the smallest endpoint there is, and it answers the only question worth asking first: is this key valid, and what may it do?
curl https://app.staging.formsnapp.com/v1/me \
-H "Authorization: Bearer fs_live_..."The machine-readable description of everything below lives at /v1/openapi.json. It is generated from the code that serves the API, so it cannot drift.
Authentication
Every request carries Authorization: Bearer <key>. Nothing else authenticates — there are no query-parameter keys and no cookies.
There is deliberately no CORS. A key used in browser JavaScript is a leaked key: it ships to every visitor who loads the page. Browsers will refuse these calls, which is a faster teacher than a paragraph in the docs. If you want a form in a browser, embed the form itself — that needs no key at all.
A missing, unknown, revoked and expired key all return the identical 401. Telling them apart would confirm which keys exist.
Scopes
A key holds only the permissions you granted when you created it. Scopes are fixed for the life of a key: widening one means creating a new key, so a leaked key can never be quietly upgraded. A write scope never implies its read.
| Scope | Allows |
|---|---|
forms:read | List and read forms |
forms:write | Create, update, publish and close forms |
responses:read | List and read responses |
responses:write | Delete responses |
Calling an endpoint without its scope returns 403 naming the one that is missing.
Pagination
List endpoints are cursor-paginated, never offset-paginated. Responses arrive constantly, and an offset page silently skips rows when new ones land mid-scan.
let cursor = null;
do {
const url = new URL("https://app.staging.formsnapp.com/v1/forms/FORM_ID/responses");
url.searchParams.set("limit", "100");
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.FORMSNAPP_KEY}` },
});
const page = await res.json();
for (const response of page.data) handle(response);
cursor = page.next_cursor;
} while (cursor);Every page carries data, has_more and next_cursor. The cursor is null on the last page. To poll for what is new instead of walking the whole list, pass ?since= with an ISO 8601 timestamp.
Errors
Errors are a shape, not a sentence: { "error": { "code", "message", "field?" } }. The code is stable and machine-readable; the message is written for a person and may change.
| Status | Code | What it means |
|---|---|---|
| 400 | BAD_REQUEST | The request itself is malformed. |
| 401 | UNAUTHENTICATED | No usable key was presented. |
| 402 | QUOTA_EXCEEDED | The workspace's plan no longer includes API access — the key is fine. |
| 403 | FORBIDDEN | The key is missing a scope. |
| 404 | NOT_FOUND | No such resource in this workspace. |
| 409 | CONFLICT | The request collides with the form's current state — an idempotency key still in flight, or questions that a form's logic depends on. |
| 422 | VALIDATION | The body failed validation; `field` names the first problem. |
| 429 | RATE_LIMITED | Too many requests. See `Retry-After`. |
A resource in someone else’s workspace returns 404, never 403 — a 403 would confirm the id exists.
Rate limits
60 requests per minute and 1,000 per hour, per key. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset; a 429 also carries Retry-After.
If you are near the limit, you are probably polling where a webhook would do the job — see below.
Idempotency
Send Idempotency-Key on a POST and a retry carrying the same value returns the first response instead of doing the work twice. Stored for 24 hours, and scoped to your key. A network timeout is the case this exists for: the request may well have succeeded, and retrying it blind is how you end up with two identical forms.
Endpoint reference
Version 1.0.0. What a form returns is a deliberate projection — id, title, status, timestamps, response count, URL and questions. Theme, layout and logic rules are internal and never appear, so we can keep changing them without breaking your integration.
Getting started
Identify the key
Returns the workspace a key belongs to and what it may do. The first thing to call when a request is failing and it isn't clear whether the key or the path is at fault.
Forms
List forms
- ?cursor
- Continue from a previous page's `next_cursor`.
- ?limit
- Rows per page, 1–100. Defaults to 25.
Create a form
Creates a draft. Send `Idempotency-Key` and a retry will return the first response instead of creating a second form.
Body
- title
- string
- required
- questions
- array
- required
Retrieve a form
Update a form
Omitting `questions` leaves them untouched. Sending them replaces every question, including their ids — answers already collected still refer to the old ones. Refused with 409 on a form that uses conditional logic, multiple steps or a Studio layout, since all three reference questions by id.
Body
- title
- string
- questions
- array
Publish a form
Makes it live. A slug is assigned on the first publish only.
Close a form
Stops accepting responses. The form must already be published.
List a form's responses
- ?cursor
- Continue from a previous page's `next_cursor`.
- ?limit
- Rows per page, 1–100. Defaults to 25.
- ?since
- ISO 8601 timestamp. Returns only responses submitted after it.
Responses
Retrieve a response
Delete a response
Also deletes any files it collected. This cannot be undone.
Webhooks
Rather than polling, have us POST to you. Workspace endpoints are configured in Settings → API & webhooks and fire across every form; per-form webhooks live in a form’s Connect panel.
| Event | Fires when |
|---|---|
response.created | Someone submits a response |
form.published | A form goes live |
form.closed | A form stops accepting responses |
Every delivery carries X-FormSnapp-Signature in the form t=<unix>,v1=<hex hmac>. The HMAC is SHA-256 over {timestamp}.{raw body} using your endpoint’s signing secret.
Reply with a 2xx. Anything else is retried with backoff; an endpoint that fails 25 attempts in a row is switched off and shown as such in settings, so a URL you decommissioned does not keep costing you retries. Every attempt, and your server’s own error text, appears in the delivery log.
Verifying a signature
Two rules decide whether this is worth anything. Verify against the raw request body — re-serialising parsed JSON changes whitespace and key order, and the signature covers the exact bytes. And reject anything whose timestamp is more than five minutes old: the timestamp is inside the signed payload precisely so a delivery someone captured cannot be replayed later.
const crypto = require("crypto");
// rawBody must be the RAW bytes, not a parsed object — re-serialising JSON
// changes whitespace and key order, and the signature covers the exact bytes.
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
String(header || "")
.split(",")
.map((p) => p.trim().split("=")),
);
const timestamp = Number(parts.t);
// Bail on anything malformed rather than throwing. A handler that throws
// returns 500, and we retry a 500 — so one junk request becomes a storm.
if (!parts.v1 || !Number.isFinite(timestamp)) return false;
// Reject anything more than five minutes old. This is what stops someone
// replaying a delivery they captured earlier.
if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// Lengths must match before timingSafeEqual, which throws on a mismatch.
if (expected.length !== parts.v1.length) return false;
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(parts.v1),
);
}import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
try:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
timestamp = int(parts["t"])
signature = parts["v1"]
except (AttributeError, KeyError, ValueError):
# Malformed header. Return False rather than raising: a handler that
# raises returns 500, and we retry a 500.
return False
# Reject anything more than five minutes old, so a captured delivery
# cannot be replayed later.
if abs(time.time() - timestamp) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)Lost the secret, or think it leaked? Rotate it in the same place you created the endpoint. The old secret stops verifying immediately, so update your server first.
Something not covered here? Get in touch.