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 regardless; 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 classic production 400.

Custom values on the response

mergeMeta(partial) records caller-computed values (a score, an experiment bucket) under meta.custom, riding the next save or amending 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);

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

On this page