Forms SDK

Headless API and React

Drive the form engine from code: the form handle, typed answers, events, and the React adapter

Everything the data attributes do, your code can do directly. Skip the container option and the SDK runs fully headless; keep it and mix both styles.

The Form Handle

SurfaceForms.init resolves to a SurfaceForm handle:

MemberWhat it does
setAnswer(questionId, state)Merges a partial state into the question's answer. Use the exact shape from the binding map.
next() / back()Linear advance and rewind. next() resolves on the step change; back() never writes.
goToStep(stepId)Your routing. Forward jumps save; jumps to a visited step rewind. Rejects unknown IDs.
submit() / disqualify()Finish the form; the terminal write is awaited, then the matching ending step is revealed.
savePartial()Explicit mid-form save (fire-and-forget beacon).
flush()Resolves when every queued write has settled. Await before programmatic teardown.
state(){ currentStepId, answers, externalStates, responseId, resumeToken, finished }. Referentially stable between mutations.
subscribe(cb) / on(event, cb)Store subscription and lifecycle events; both return an unsubscribe function.
mergeMeta(partial)Record caller-computed values under meta.custom (see below).
identify()Re-run visitor identification after an SPA route change.
validateEmail(email)On-demand deliverability verdict. See Email validation.
capabilities{ enrichment, emailValidation, tracking }: what actually runs in this environment.
runtimeConfigThe fetched form record.
markViewed()Fire the viewed events once. The binding layer does this via IntersectionObserver; headless callers do it themselves.
destroy()Tear down every binding. One engine per page life; re-running init without destroy creates duplicate responses.

Events

form.on("saved", ({ responseId, resumeToken, finished }) => { /* persist for resume */ });

Events: viewed, started, stepChanged ({ fromStepId, toStepId }), stepCompleted, completed, disqualified, saved, error ({ scope, error }), scheduler (see Schedulers).

Reading Answers

state().answers is keyed by step ID, then question ID:

const email = form.state().answers["step_about"]?.["q_email"]?.email;

setAnswer records under the current step, so set each step's answers before next() or goToStep(). Every answer rides every save either way. A late setAnswer only lands under the wrong step for per-step reads and analytics (debug: true warns).

Typed Answer States

One exported type per component (ShortInputAnswer, DropdownAnswer, MultipleOptionsAnswer, IdentityInfoAnswer, SchedulerAnswer, ...) plus the union KnownAnswerState:

import type { MultipleOptionsAnswer } from "@surface-labs/forms-sdk";

const answer: MultipleOptionsAnswer = {
  type: "MultipleOptionsQuestion",
  choices: [
    { key: "Sales Ops", value: true, index: 0 },
    { key: "Leadership", value: false, index: 1 },
  ],
};
form.setAnswer("q_role", answer);

Choice answers are structured lists, not label strings. Copy each question's answerShape from the binding map. The wrong shape is the most common cause of a 400 in production.

Custom Values on the Response

mergeMeta(partial) records caller-computed values (a score, an experiment bucket) under meta.custom. They ride the next save, or amend a finished response. Only that namespace persists; visitor input belongs in answers.

React

Two patterns, depending on who renders the inputs.

Fully Headless (Controlled Inputs)

init without container, render everything from the hook:

import { SurfaceForms, type SurfaceForm } from "@surface-labs/forms-sdk";
import { useSurfaceForm } from "@surface-labs/forms-sdk/react";
import { useEffect, useState } from "react";

export function QualifyForm() {
  const [form, setForm] = useState<SurfaceForm | null>(null);

  useEffect(() => {
    let handle: SurfaceForm | null = null;
    let disposed = false;
    void SurfaceForms.init({
      environmentId: "env_xxx",
      formId: "form_xxx",
      apiBaseUrl: "https://forms.withsurface.com",
    }).then((created) => {
      if (disposed) created.destroy();
      else {
        handle = created;
        setForm(created);
      }
    });
    return () => {
      disposed = true;
      handle?.destroy();
    };
  }, []);

  if (!form) return null;
  return <QualifySteps form={form} />;
}

function QualifySteps({ form }: { form: SurfaceForm }) {
  const { state, setAnswer, goToStep, submit } = useSurfaceForm(form);
  // render inputs from state, setAnswer onChange, navigate from your handlers
}

useSurfaceForm is a useSyncExternalStore adapter: state re-renders on engine mutations, stable between them. React 18+ is an optional peer dependency; the main entry stays React-free.

Data-attribute Binding Inside React

React renders the markup, the SDK captures answers. Two ownership rules:

  1. hidden belongs to the SDK. Emit it as a constant in the initial JSX for every step after the first; a hidden={step !== current} expression re-hides the step the SDK just revealed.
  2. Keep bound inputs uncontrolled and mounted. Listeners attach at init; a remounted input has no listener, and a controlled value fights the visitor's typing. Read answers from form.state().answers, not React state.

In the effect cleanup, call destroy() alone; beacons survive unmount and the finishing write was already awaited by submit(). On SPA route changes, call form.identify().

Running in Node

Node 18+, native fetch, no DOM shim; the standard way to verify a record without a browser:

import { SurfaceForms } from "@surface-labs/forms-sdk";

const form = await SurfaceForms.init({ environmentId, formId, apiBaseUrl, journey: false });
form.setAnswer("q_email", { type: "ShortInput", input: "qa@example.com" });
await form.submit(); // resolves once the server has the finished response
console.log(form.state().responseId);

Without the DOM there is no group auto-build, so set choice questions' structured shapes yourself. SDK warnings go to stderr (console.warn), so capture 2>&1 before grepping.

Ready to Get Started?

See it on your own site, or open the app and build the first form.

Need Help?

Setup problems, blocked domains and CRM push failures live in the Help Center.

Feature Requests?

Tell us what is missing. We would rather hear it than guess.

On this page