Skip to content
Back to home
Developers

FormSnapp API

Create forms and pull responses from your own code. Everything below is server-to-server: an API key belongs on your server, never in a browser.

Last updated:

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
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.

ScopeAllows
forms:readList and read forms
forms:writeCreate, update, publish and close forms
responses:readList and read responses
responses:writeDelete 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.

Paging through every response
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.

StatusCodeWhat it means
400BAD_REQUESTThe request itself is malformed.
401UNAUTHENTICATEDNo usable key was presented.
402QUOTA_EXCEEDEDThe workspace's plan no longer includes API access — the key is fine.
403FORBIDDENThe key is missing a scope.
404NOT_FOUNDNo such resource in this workspace.
409CONFLICTThe request collides with the form's current state — an idempotency key still in flight, or questions that a form's logic depends on.
422VALIDATIONThe body failed validation; `field` names the first problem.
429RATE_LIMITEDToo 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

GET/v1/meany scope

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

GET/v1/formsforms:read

List forms

?cursor
Continue from a previous page's `next_cursor`.
?limit
Rows per page, 1–100. Defaults to 25.
POST/v1/formsforms:write

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
GET/v1/forms/{id}forms:read

Retrieve a form

PATCH/v1/forms/{id}forms:write

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
POST/v1/forms/{id}/publishforms:write

Publish a form

Makes it live. A slug is assigned on the first publish only.

POST/v1/forms/{id}/closeforms:write

Close a form

Stops accepting responses. The form must already be published.

GET/v1/forms/{id}/responsesresponses:read

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

GET/v1/responses/{id}responses:read

Retrieve a response

DELETE/v1/responses/{id}responses:write

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.

EventFires when
response.createdSomeone submits a response
form.publishedA form goes live
form.closedA 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.

Node.js
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),
  );
}
Python
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.