Skip to content

Add a form to your site

There are two ways to put a form built in ebitex Forms on your site:

Embed @ebitex/forms-sdk
What you do Paste two tags into a page Fetch the form on your server and render it with React components, or your own
Markup and styling ebitex’s, inside a frame Yours
Needs a key No Yes, on your server only
Needs a developer No Yes

Either way, a form accepts responses only while its status is Active. Set it in the Forms editor by selecting the status badge next to the form’s title.

Both options start from a form that already exists. Build one in Forms, then set it Active. If you do not have an ebitex organization yet, Set up your organization covers signing up.

An Active form is published at an address built from your organization’s slug and the form’s Id:

https://forms.ebitex.io/public/form/{organization}/{form-id}

To embed it:

  1. In Forms, open the form. On its Overview page, next to the public address, select Embed.
  2. Select Copy, and paste the result into your page where the form should appear.

The snippet looks like this:

<iframe
id="ebitex-form-contact-us"
src="https://forms.ebitex.io/public/form/acme-corp/contact-us?embed=1"
title="Contact us"
style="width:100%;border:0;height:600px;"
loading="lazy"
></iframe>
<script src="https://forms.ebitex.io/embed.js" async></script>

Keep both tags. ?embed=1 makes the form report its height to your page, and embed.js resizes the frame to match whenever the height changes: when the respondent moves between steps, when a validation message appears, and when the form is completed. The frame starts at 600 pixels until the first report arrives. Without the script the form still works, but at a fixed height with its own scrollbar. One copy of the script sizes every embedded form on the page.

The embed works on any domain. There is nothing to configure or approve first.

Recognize the page’s visitor in an embedded form

Section titled “Recognize the page’s visitor in an embedded form”

If your page already recognizes its visitors with @ebitex/visitors-sdk, the embedded form can recognize the same visitor, including in browsers that keep no cookie for a frame. Turn on the embed option and nothing else changes:

<script type="module"
src="https://cdn.jsdelivr.net/npm/@ebitex/visitors-sdk@0/dist/script.js"
data-ebitex-key="ebx_your_browser_key"
data-ebitex-embeds></script>

With the package, call connectEmbeds(visitors) once, after you create the visitor object. It returns a function that stops it.

How it works, so you can judge it:

  • The form frame announces itself to your page with a message that carries nothing. Your page answers only an <iframe> of its own whose src is on the form host, and sends the token only to that exact origin, never to * and never in a URL. The frame accepts a token only from the page that embeds it.
  • The frame keeps the token in memory, so it is never written to a cookie in the frame, and a reload asks your page for it again. The frame sends it to ebitex in a request header on its own requests.
  • Consent is still decided by ebitex from the organization’s settings and the browser’s own signals (Global Privacy Control, Do Not Track), not by the page. If recognition is not granted, the frame receives a short-lived session token instead and nothing is kept.
  • Your page receives back only a token and the consent state, which it handles exactly like any other answer (kept on the device only while recognition is granted). It never receives the visitor’s answers.
  • Earlier answers are filled in after the form has appeared, and only into fields the respondent has not touched and that are empty. The change is announced to screen readers.
  • A visitor who uses “Forget this browser” in the frame is forgotten on your page too.

Nothing changes for a page that does not turn this on, or for any snippet already copied.

To use a form host other than https://forms.ebitex.io, set data-ebitex-forms-origin on the script tag, or pass formsOrigin to connectEmbeds.

Add prefill.<field>=<value> to the frame’s src to start a field with a value, where <field> is the field’s name in the Forms editor:

https://forms.ebitex.io/public/form/acme-corp/contact-us?embed=1&prefill.email=ada%40example.com&prefill.topic=billing

URL-encode each value. Prefilled answers stay editable. An empty value, or a name the form does not have, is ignored.

When a respondent completes an embedded form, the frame posts this message to your page:

{ "source": "ebitex-forms-embed", "type": "submitted" }

It carries no answers: those are already saved. Listen for message events on window, and act only when event.data has exactly these two members and event.source is your frame’s contentWindow.

These follow from how browsers treat one site shown inside another, and none can be configured:

  • Returning visitors may not be recognized in an embed on another site. The form’s visitor cookie is partitioned per embedding site in Chromium-based browsers and in Firefox, so a visitor is recognized on your site but not carried to other sites or to the form’s own address. Safari, and Firefox with strict tracking protection, keep no cookie in a frame at all, so a returning visitor looks new there. To recognize the same visitor as the rest of your page in every browser, see Recognize the page’s visitor in an embedded form. Resuming a half-finished response and prefilling from a connected integration depend on it. Details held by a connected integration are shown only after the visitor confirms an emailed link. Recognizing a visitor also needs their consent; see Consent and returning visitors.
  • The form follows the visitor’s light or dark preference, not your page’s theme.
  • The form needs JavaScript. If its scripts fail to load, the frame is empty. Keep a plain link to the form’s public address on the page as a fallback.
  • Colors follow whatever Look and feel is set for the form (or its organization’s default) in Forms — not necessarily ebitex’s own branding. Change it from the form’s own Look and feel tab.

Option 2: Render the form with @ebitex/forms-sdk

Section titled “Option 2: Render the form with @ebitex/forms-sdk”

The SDK splits into two entry points, and the split is the security boundary:

  • @ebitex/forms-sdk fetches a form’s definition. It needs a key, so it runs only on your server.
  • @ebitex/forms-sdk/react renders the form in the browser and submits it. It never sees the key.
Terminal window
npm install @ebitex/forms-sdk

React and React DOM 19 or later are needed only for the components. A server that only fetches definitions does not need them.

You must be an Owner of the organization.

  1. In Hub, open API Keys and select Create key.
  2. Name the key after where it will be used, such as “Marketing site”.
  3. Choose how long it lasts: 30 days, 90 days, 1 year or never. An expired key is refused exactly like a revoked one.
  4. Keep the Read Forms via the SDK scope selected. A key without it authenticates, then every request is refused with 403.
  5. Select Create key, and copy the key. It is shown only once and cannot be recovered: if you lose it, revoke it and create another.

The key can read your organization’s form definitions and nothing else. It cannot read submissions or change anything.

createFormsClient takes the key, and getForm takes the form’s Id from the Forms editor:

fetch-form.ts
import { createFormsClient, FormsApiError, type SdkForm } from '@ebitex/forms-sdk'
/**
* Server code only. The key can read every form in your organization, so it must never be sent to
* a browser. Create the loader once, with the key from your server's configuration, and pass the
* form it returns to your page.
*/
export function createFormLoader(apiKey: string) {
const forms = createFormsClient({ apiKey })
return async function loadForm(externalId: string): Promise<SdkForm | null> {
try {
// The form's Id, from the Forms editor.
return await forms.getForm(externalId)
} catch (error) {
if (error instanceof FormsApiError && error.status === 404) {
return null // No form with that Id in this organization.
}
if (error instanceof FormsApiError) {
// `body` is the parsed error response. Quote `requestId` if you contact support.
console.error(`Forms API answered ${error.status} [request ${error.requestId ?? 'none'}]`, error.body)
}
throw error
}
}
}

The result is a plain object you can pass to the browser: the form’s name, its steps and actions, its status, a submittable flag and its submissionUrl. A Draft or Archived form can be fetched, so you can build against it before publishing, but submittable is false and responses are refused until it is Active. See Get a form for the full response.

Any response other than a success throws FormsApiError, with the status in status, the parsed response in body, and the request’s reference in requestId:

Status Cause What to do
401 The key is missing, malformed, revoked or expired. Check the key, or create a new one.
402 The organization’s subscription needs attention. Resolve billing in Hub.
403 The key lacks the Read Forms via the SDK scope. Create a key with that scope.
404 The organization has no form with that Id. Check the Id.
409 Two fields in the form share a submission key. Rename one of them in the Forms editor. The response names them.
429 Too many requests. Wait for the time in the Retry-After header, then retry.

Requests are limited per IP address and per organization, depending on your plan. Fetching on each page render stays well within both, and caching the definition on your server keeps you further under them.

<EbitexForm> renders the definition and submits it to the form’s submissionUrl, the same public endpoint the hosted page and the embed use:

render-form.tsx
import { EbitexForm } from '@ebitex/forms-sdk/react'
import type { SdkForm } from '@ebitex/forms-sdk'
/**
* Runs in the browser. It receives the definition your server fetched, never the key, and submits
* to the form's own public `submissionUrl`.
*/
export function ContactForm({ form }: { form: SdkForm }) {
return (
<EbitexForm
form={form}
onSubmitted={(answers) => {
// The response is saved. `answers` is what was sent, keyed by each field's submissionKey.
console.log('Submitted', answers)
}}
onError={(error) => {
// A network failure or an unexpected response. Validation errors are shown on the
// fields themselves and never reach this callback.
console.error('The form could not be submitted', error)
}}
/>
)
}

It handles the rest of the form’s behavior for you:

  • Steps. A multi-step form shows one step at a time. Each Next saves the answers so far, so a respondent who leaves partway through is not lost.
  • Conditions. Fields and steps that depend on earlier answers appear and disappear as the respondent answers.
  • Validation. Answers are checked in the browser with the same rules the server applies, and any error the server returns is shown on its field.
  • Accessibility. Every field has a real label, required and invalid fields are marked for assistive technology, and each error message is linked to its field.
  • Drafts. A form that is not Active renders read-only, with a notice, rather than failing on submit.
Prop Purpose
form The definition your server fetched.
onSubmitted Called with the answers after the final step is saved.
onError Called when a submission fails for a reason other than validation, such as a network error.
className, style Applied to the form’s root element.
fieldComponents Replaces the component for one or more field types: Input, Textarea, Number or Choice. Types you do not replace keep their default.

A form can include a Lookup field: a search box that finds an existing record as the respondent types, with a row for what they typed when it is not found. <EbitexForm> renders it with nothing extra to set up, searching the same public endpoint family the form submits to, so no key is involved. If the search is unavailable the field becomes an ordinary text input and the form still submits.

Besides the answer, a lookup submits a second value under its key followed by -existing: "true" when the respondent picked a match, "false" when they typed their own. A condition can read it, which is how a form asks for more detail only when the answer is not already on file.

Prop Purpose
lookup Replaces where a Lookup field searches, for example to go through your own server. An object with a search(field, query, signal) method that resolves to { value, label } options; reject to fall back to plain text, and honour signal.

If you render the form yourself, call collectSubmissionKeys(nodes, data) rather than collecting field keys when you decide what to send, so the -existing value is included. An older @ebitex/forms-sdk does not know the Lookup type, so use the current version on any site that renders a form with one.

For default styling, import @ebitex/forms-sdk/styles.css once in your app. Its colors, radius and spacing are CSS custom properties, such as --ebitex-forms-accent, --ebitex-forms-radius and --ebitex-forms-border, set on the .ebitex-forms root. To style it entirely yourself, skip the stylesheet and target the ebitex-forms__ classes, such as ebitex-forms__label, ebitex-forms__control and ebitex-forms__error.

The default stylesheet reacts to dark mode two ways: it follows prefers-color-scheme, and it follows a .dark class on any ancestor element, for an app that already tracks an explicit light/dark choice rather than the operating system’s. Add data-ebitex-theme="light" to .ebitex-forms (or an ancestor) to opt out of both and force light regardless. If you set your own colors and want a distinct dark-mode value rather than the same one in both modes, set it again under your own .dark or prefers-color-scheme: dark rule — deriveDarkVariant, exported from @ebitex/forms-sdk, suggests one from a #rrggbb light color if you’d rather start from a suggestion than pick your own.

A multi-step form keeps its progress against a visitor token that the API issues. Treat it as an opaque string. The SDK holds it in memory only: it never writes to localStorage, sessionStorage, a cookie or IndexedDB, so by default every page load is a new visitor and nothing is remembered.

Recognizing a respondent across visits needs their consent. Tell the form what they chose, and give it somewhere to keep the token if you want it to survive between visits:

<EbitexForm
form={form}
consent={{ cmp: 'granted' }}
storage={{
get: () => localStorage.getItem('visitor'),
set: (token) => localStorage.setItem('visitor', token),
remove: () => localStorage.removeItem('visitor'),
}}
/>
  • set is called only when the API reports that recognition is granted. remove is called whenever it is not, when the respondent withdraws, and when the response says nothing about consent.
  • consent.cmp is 'granted' or 'denied', from your consent banner. gpc and dnt default to the browser’s Global Privacy Control and Do Not Track settings. An explicit banner choice outranks them.
  • With no signal at all, the organization’s default applies. It is denied until an Owner changes it in Hub (Visitor consent), so out of the box nothing is recognized across visits. A respondent who is not recognized can still complete the form; only cross-visit features such as prefill are off.
  • If you keep the token anywhere other than through storage, the same rule applies to you: store it only while recognition is granted, and remove it the moment it is not.

createVisitorSession shares one session between forms and exposes setConsent and forget. forget asks the API to forget the browser: it rotates the visitor’s identifier, removes the links to any connected integration, and clears the token.

Share one visitor with the rest of your site

Section titled “Share one visitor with the rest of your site”

If your site also uses @ebitex/visitors-sdk to recognise visitors (see Recognise visitors with their consent), you do not need a second visitor in each form. sharedVisitorSession turns that object into a session the form shares:

shared-visitor.tsx
import { useMemo } from 'react'
import { sharedVisitorSession, type SdkForm, type SharedVisitors } from '@ebitex/forms-sdk'
import { EbitexForm } from '@ebitex/forms-sdk/react'
// `visitors` is the object `createVisitors` returns in @ebitex/visitors-sdk, created once for the whole page.
// Anything with this shape works, so this package never imports that one.
export function ContactForm({ form, visitors }: { form: SdkForm; visitors: SharedVisitors }) {
// One session per visitors object. The form now sends the page's token and consent signals, and what its responses say
// goes back to the page's visitor, which keeps the token on the device only while recognition is granted.
const session = useMemo(() => sharedVisitorSession(visitors), [visitors])
return <EbitexForm form={form} visitor={session} />
}
  • One identity. The form sends the page’s token and consent signals, so a returning visitor is recognised in the form exactly as they are everywhere else on the page.
  • One set of storage rules. What the form’s responses say (a possibly new token, and whether recognition is granted) is handed to the page’s visitor, which keeps the token in its cookie only while recognition is granted and removes it otherwise.
  • Forget means everywhere. forget on this session forgets the device across the form, the page and what the device was linked to, and rejects if that fails so you can tell the visitor.
  • Nothing changes if you do not use it. A form with no shared session keeps its own, exactly as described above. The package does not import @ebitex/visitors-sdk: any object with the same shape works.

To render the form some other way, use the definition directly and post the answers yourself. Key each answer by its field’s submissionKey, never by the field’s label or name: a field can be keyed differently from the name shown in the editor.

submit-answers.ts
import { validateSubmission, type SdkForm, type SubmissionData, type ValidationErrors } from '@ebitex/forms-sdk'
/**
* The visitor token the API issued on the previous save, held in memory only. It is what makes the steps of
* a multi-step form collect into one response. It is deliberately not written to localStorage, a cookie or
* anywhere else: recognizing a respondent across visits needs their consent, so persist it only while the
* response says `consent.recognition` is "granted" (see the consent guide on the developer site).
*/
let visitor: string | undefined
/**
* Submits answers without the React components. Returns field errors to show, or an empty object
* when the response was accepted. Pass `complete: false` to save one step of a multi-step form.
*/
export async function submitAnswers(
form: SdkForm,
data: SubmissionData,
complete = true,
// The respondent's choice in your consent banner, if you have one. Leave it out when you do not know.
cmp?: 'granted' | 'denied',
): Promise<ValidationErrors> {
// The same rules the server applies. Required fields are enforced only on completion.
const errors = validateSubmission(form, data, { requireAll: complete })
if (Object.keys(errors).length > 0) return errors
// A public endpoint: no key, ever. `consent` is what you know about the respondent's choice; send
// what you saw and the API decides.
const response = await fetch(form.submissionUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ visitor, data, complete, consent: { cmp } }),
})
if (response.status === 400) {
const body = (await response.json()) as { error?: string; errors?: ValidationErrors }
if (body.error === 'validation_failed' && body.errors) return body.errors
}
if (!response.ok) {
throw new Error(`The submission failed with status ${response.status}`)
}
// Use the token the API returned from now on.
const saved = (await response.json()) as { visitor?: string }
visitor = saved.visitor
return {}
}

The request body is JSON:

{ "visitor": "v1.CfDJ8…", "data": { "email": "ada@example.com" }, "complete": true, "consent": { "cmp": "granted", "gpc": false, "dnt": false } }
  • visitor is the token the API returned on the previous save (omit it on the first). Send it with every save, so the steps of one respondent’s form collect into one response. A missing, expired or unrecognized token is not an error: the respondent is simply a new visitor, and the response carries the token to use from then on. An unsigned UUID, which earlier versions of this guide had you generate, is still accepted for a limited time; prefer the token.
  • consent is optional and carries what you know about the respondent’s consent: cmp ("granted" or "denied", from your consent banner), gpc and dnt (the browser’s Global Privacy Control and Do Not Track settings), and cmpString (your banner’s own consent string, kept as evidence). Send what you saw; the API decides.
  • complete: false saves progress on a multi-step form. complete: true finishes the response, and only then are required fields enforced.
Status Meaning
201 or 200 Saved. 201 when this started a new response, 200 when it added to one in progress. The body includes visitor (the token to send next) and consent (recognition, source, tokenKind, tokenLifetimeSeconds and the wording messages). Persist the token only when consent.recognition is "granted".
400 { "error": "validation_failed", "errors": { … } }, with a message per submission key.
404 The form does not exist or is not Active.
413 The request body is too large.
429 Too many submissions. Wait for the time in the Retry-After header.

To let a respondent forget their browser, post { "visitor": "<token>" } to the same address with /submissions replaced by /forget. It answers 200 with { "message": "…" } whether or not the token was valid, so it cannot be used to probe identifiers. A valid token stops working, the visitor’s identifier is rotated, and any link to a connected integration is removed.

<EbitexForm> sends the tags of the page’s address (utm_source, utm_medium, utm_campaign) with the submission, so a person who submits from a tagged link is recorded in Audience with where they came from. It stores nothing: to keep a first touch from an earlier page, pass it yourself with the attribution prop (a value, or a function asked at each submit), or pass () => undefined to send none. If you post to submissionUrl yourself, add the same member to the body: "attribution": { "source": "linkedin", "medium": "social", "campaign": "whitepaper-composable" }, each at most 100 characters. The text is untrusted and cleaned by the server, and Forms keeps the first value a response is given.