Skip to main content

Respondents & identity

Identify people, save progress, recover drop-off, and control repeat responses.

Identify signed-in users, save unfinished answers, and let people return to edit a response. These features require @usefillo/* 0.7.0 or newer.

Identify who's filling the form

Pass your signed-in user with the respondent prop. Their ID, email, and name appear with the response in the dashboard, webhooks, Zapier, and CSV exports. The person view shows all responses from that respondent. respondent.id is the user ID from your app.

tsx
"use client";

import { FilloForm } from "@usefillo/react";

// Pass your signed-in user. Every response this person submits now arrives
// with their id, email, and name attached — in the responses grid, webhooks,
// Zapier, and CSV export. Safe to pass late (after your session loads).
export function NpsForm({ user }: { user: { id: string; email: string; name: string } }) {
  return (
    <FilloForm
      formId="nps"
      respondent={{ id: user.id, email: user.email, name: user.name }}
    />
  );
}

The same option works on <FilloProvider>, the framework-free renderForm() (with setRespondent() to bind late), and createFormController().

ts
import { renderForm } from "@usefillo/dom";

// The DOM renderer accepts the same respondent context as the React renderer.
const form = renderForm("#form", {
  formId: "nps",
  respondent: { id: user.id, email: user.email, name: user.name },
});
// Late-bind it once your session resolves:
form.setRespondent({ id: user.id, email: user.email, name: user.name });

By default, respondent identity is not verified. Browser code can send any user ID. Turn on verification before using identity for access control or allowing someone to update an existing response.

Verify identity

In Settings > Developers > Identity verification, generate a project secret. Sign the versioned profile payload below on your server and send its hash with the exact same ID, email, name, and traits. Changing any signed value invalidates the signature. Legacy ID-only hashes still verify account identity, but Fillo discards their unsigned profile values. Host signatures attest your app’s account context; only an emailed confirmation code proves inbox ownership.

Compute the hash server-side:

ts
// On YOUR server — never in the browser (the secret must not ship to clients).
// Copy the secret from Fillo → Settings → Developers → Identity verification.
import { createHmac, hkdfSync } from "node:crypto";

export function respondentHash(user: {
  id: string; email?: string; name?: string;
  traits?: Record<string, string | number | boolean>;
}): string {
  // Use trimmed values; omit empty optional values. Sign exactly what you pass.
  // Bounds: id <=128,
  // email <=254, name <=200; traits <=32, keys <=64, string values <=500.
  const payload = ["fillo-identity-v2", user.id, user.email ?? null,
    user.name ?? null, Object.entries(user.traits ?? {}).sort(
      ([a], [b]) => a < b ? -1 : a > b ? 1 : 0,
    )];
  // Separate profile signing from legacy ID-only signatures.
  const key = Buffer.from(
    hkdfSync("sha256", process.env.FILLO_IDENTITY_SECRET!, "fillo-identity-v2", "respondent-profile", 32),
  );
  return createHmac("sha256", key)
    .update(JSON.stringify(payload))
    .digest("hex");
}

Then pass it with the identity:

tsx
// Pass the server-computed hash alongside the identity. Once your project
// has a secret, Fillo records identity ONLY when the hash validates, so a
// script on the page can't impersonate or enrich another user's profile.
<FilloForm
  formId="nps"
  respondent={{ id: user.id, email: user.email, name: user.name, traits: user.traits, hash }}
/>

Never compute the hash in the browser because that would expose the secret. If verification is on and the hash is missing or invalid, Fillo records the response without a respondent.

Hold unverified submissions

On a form that lives behind sign-in — in-product feedback, member surveys, account requests — every real response comes from a user your server can vouch for. Once verification is set up, turn on Hold unverified submissions for review in the form's Respondents settings. From then on, a submission without a valid hash doesn't go anywhere: it's held for your review instead.

A held submission is stored, but nothing is delivered or shown. No webhooks, no Google Sheets, Notion, or Slack, no notification or receipt emails, no Zapier — and it stays out of exports, analytics, and response counts. The submitter sees the same confirmation as everyone else; nothing tells a script its submission was held.

Held submissions wait under Held on the form's responses page. Release the legitimate ones — a released response is delivered everywhere as if it had just been accepted, one response.created event carrying the original submission time — and delete the rest.

Leave this off on forms with anonymous traffic: a shared link, a public page, anywhere respondents aren't signed in. There, every genuine response would be held too. The dashboard setting requires the project identity secret. A code-defined form can stage settings.trust before the secret exists, but the policy is fail-closed: every submission is held until verification is set up or the policy is removed. See the SDK reference.

Require a human check

For forms with anonymous traffic, where verification isn't an option, turn on Require a human check (Turnstile) in the form's Respondents settings. The SDK renders a Cloudflare Turnstile check before the form can be submitted, and Fillo verifies it on the server — a submission without a valid, un-expired token is turned away with a clear retry, so scripted spam is stopped at the door. Fillo hosts the keys, so there's nothing to configure and no Cloudflare account to set up. (Self-hosting Fillo? The gate needs your own Turnstile keys — a deploy without them accepts submissions and says so in telemetry and the form's settings.)

The check works wherever the form lives — your own domain, a hosted link, any embed — because it loads in a small Fillo-hosted frame inside the form; your page never loads Cloudflare's script, and a CSP needs only frame-src for your Fillo origin. By default it's invisible: most respondents pass silently and never see a box; the widget appears only when Cloudflare needs the person to act, then folds away. Pass challengeAppearance="always" for the classic always-visible box, and challengeTheme ("light"/"dark") when your app manages its own theme. It's privacy-friendly (no cross-site tracking or interaction data sold) and loads nothing extra until a form actually requires it — a form with the check off pulls in zero extra scripts and no frame. The server is the real gate: the widget is there for the person, but Fillo rejects a missing or forged token regardless of what the page rendered. It's independent of verified-only — a form can require both. Code-defined forms set settings.trust.challenge in the schema instead.

Save and resume

Turn on Save and resume in a form's Respondents settings. The SDK then autosaves in-progress answers as the visitor types and restores them when they return — after a reload, a closed tab, or (for a verified respondent) on another device. A "picked up where you left off" notice appears with a Start over action. Unsubmitted drafts are deleted on submit, on start over, or automatically 7 days after the last change. No code changes — it's a per-form setting the renderer honors. Headless UIs can read resumedDraft and call resetDraft() / flushDraft() (see the SDK reference).

Recover drop-off

Once a form saves progress, four more per-form settings turn abandoned starts into recoverable ones. All are off by default and none carry answer content off the platform.

Configure these recovery, email, and pre-submission visibility controls in the dashboard. Public-key code sync preserves them and ignores incoming values for draftAnswersVisible, resumeEmails, resumeUrl, and draftDigest.

  • In-progress counts show how many people are mid-fill (and how many are identified) in Insights and on Responses — no extra opt-in beyond Save and resume.
  • See in-progress answers (draftAnswersVisible) adds an "In progress" tab to the responses page where your team can read what someone has typed so far and help them finish. It exposes pre-submission content to your workspace, so it's a separate opt-in with its own consent copy — disclose it in your privacy policy. The answers are visible only in the dashboard; the draft token stays the only public read capability.
  • Resume emails (resumeEmails) send a respondent one "pick up where you left off" link when they leave a form idle for a day — to an address they entered in the form, at most once per draft and three times per inbox per hour, never with any answers. A host-supplied profile email alone does not receive resume mail. Set resumeUrl to land the link inside your product; the draft reference rides the URL fragment and the SDK adopts it on load.
  • Drop-off digest (draftDigest) emails your notification address a daily summary — who abandoned the form and where they stalled, verified names only, counts not content, with a link to the dashboard.

Your app can also subscribe a webhook to the draft.abandoned event (opt-in per webhook, in the webhook settings) to run your own nudges. The payload is a signal — form, draft page, and the verified respondent id where known — never answer content. See Webhooks.

One response per person

One Limit responses setting in a form's Respondents settings decides who counts as the same responder and what happens when they return. Pick "one response per":

  • Browser: same device, anonymous. Best for ratings, polls, and "was this helpful"; no sign-in or details needed.
  • Email or Phone: a contact field they fill in. Recognized by their answer, so it is a soft self-claim, not a guarantee. Make the field required so every response is counted.
  • Verified email: for the form's Fillo link, where there is no app to sign people in. Before the form appears, the person enters their email and types the 6-digit code Fillo sends them; that device then stays confirmed for the project for 30 days. Best for forms people come back to, such as a daily diary or a check-in.
  • Signed-in user: the identify() respondent supplied by your embed. Without verification it is still a page-supplied claim; with a project secret and valid hash it becomes verified. Use this instead of Verified email in your own app: an embedded form can't show the code step.

Optionally add "…per" a field, such as an article or product id or a date, so one shared form dedupes once per article or once per day instead of once overall. On a repeat, either keep their first answer, or (Verified email or Signed-in user) let them update it in place, which re-anchors to the current form version and emits a response.updated event. Reading and prefilling the person's previous answers requires a verified identity.

Whichever on-repeat behavior you pick, a person-keyed form refuses any submission it can't attribute to a person — keep and update mode alike. So a Signed-in user limit needs identify() on every embed, and update in place additionally requires a verified respondent: an unverified claim can create a first response but never overwrite one.

Email and phone answers are provided by the respondent and are not verified. They can reduce accidental duplicates, but should not control access or permissions. A signed-in user is only verified after you configure the project identity secret and pass a valid hash generated on your server.

With Verified email and let them update it, a returning person sees their earlier answers filled in and sending saves their changes. On a form limited "…per" a date or another answer, picking a value they already sent loads that entry, and the button reads Save changes. If they typed answers before picking it, their answers stay and the button reads Replace entry. Either way the entry is replaced, never dropped. A "Not you?" link signs the device out.

The code proves the person controls the inbox, which is enough to keep someone's entries together and let them correct one. It is not strong authentication, so don't use it to guard sensitive records. Codes expire after 10 minutes, work once, and allow 5 tries. Fillo stores only a keyed hash of each code. Forget person signs out every device that person confirmed.

Erase a person (GDPR)

From the person view (filter the responses grid to one respondent), Forget person removes their profile, drafts, and identity across the project while leaving the answers anonymous; Delete person + responses also deletes their project responses and the uploaded files in your connected storage. Both are project-scoped because respondent identity is isolated per project.

  • Save and resume: Restore progress and understand draft retention.
  • Security: Review credential and verified identity boundaries.

This page for agents: /docs/respondents.md · index at /llms.txt

Updated

Was this page helpful?