Skip to content

Profile lookups and signed identity

Fill attributes and prefill on the server when a form loads, for a user you have verified.

Signed identity

Your server proves who the user is by signing the uid. uid_sig is the lowercase hex HMAC-SHA256 of the uid, keyed with the workspace identity secret (created or rotated in the workspace identity settings; it is shown once). An unsigned or badly signed uid never unlocks a profile lookup, saved answers or a journey stage; it still buckets experiments. Backends calling with an API key are trusted without a signature.

ts
import { createHmac } from "node:crypto";
const uidSig = createHmac("sha256", process.env.FORMFLOW_IDENTITY_SECRET!).update(userId).digest("hex");

When the workspace requires signed identity, an unsigned uid is also ignored for experiments and analytics, and the visitor is treated as anonymous.

Profile sources

Create a source once per workspace (integration:manage); the form references it with profileLookup.sourceId. Secrets are encrypted and never returned.

bash
curl -X POST https://YOUR_HOST/api/v1/profile-sources \
  -H "Authorization: Bearer $FORMFLOW_API_KEY" -H "Content-Type: application/json" \
  -d '{"kind":"endpoint","name":"App profile","url":"https://api.example.com/formflow/profile","secret":"a-long-random-secret"}'
# PostHog person properties:
#   {"kind":"posthog","name":"PostHog","host":"https://us.posthog.com","projectId":"12345","apiKey":"phx_..."}
KindWhat FormFlow callsNotes
endpointYour HTTPS URL, POST with JSON {"uid","workspace"}https only. Requests go through the SSRF-safe client.
posthogGET /api/projects/{id}/persons/?distinct_id=uidPersonal API key. Read only for a uid whose signature verified.

The signed request to your endpoint

text
POST /formflow/profile
X-FormFlow-Timestamp: 1767225600
X-FormFlow-Signature: sha256=<hex>
Content-Type: application/json

{"uid":"u_42","workspace":"wsp_123"}

The signature is HMAC-SHA256 over `${timestamp}.${rawBody}` keyed with the source secret. Verify it and reject old timestamps, then answer with flat or nested JSON.

ts
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(req: { headers: Record<string, string>; rawBody: string }, secret: string) {
  const ts = Number(req.headers["x-formflow-timestamp"]);
  if (!Number.isFinite(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
  const want = "sha256=" + createHmac("sha256", secret).update(`${ts}.${req.rawBody}`).digest("hex");
  const got = req.headers["x-formflow-signature"] ?? "";
  return got.length === want.length && timingSafeEqual(Buffer.from(got), Buffer.from(want));
}
// respond: { "first_name": "Maya", "plan": { "name": "pro" }, "company": "Acme" }

Map the answer

json
{ "profileLookup": {
    "sourceId": "psrc_abc",
    "timeoutMs": 1200,
    "cacheSeconds": 60,
    "map": [
      { "from": "plan.name", "to": "plan", "as": "attribute" },
      { "from": "first_name", "to": "first_name", "as": "attribute" },
      { "from": "company", "to": "company_q", "as": "field" }
    ] } }

from is a dot path into the JSON (for PostHog, into the person properties). Only text, number and boolean values are taken. as: "attribute" must name a declared attribute; as: "field" prefills a field, and with skipIfKnown skips it.

Timeout, failure and cache

The lookup waits at most timeoutMs (200 to 3000, default 1200). On a timeout, an error, a non-2xx status or bad JSON the form loads without the profile. Results are cached per source and uid for cacheSeconds in each server process.

Resume by user

Turn on settings.personalization.resumeByUid and set resumeTtlHours (default 72). With a usable uid, the next start returns the user's open partial, with its answers, on any device, until the time is up. Each resume issues a new token, so an older link stops working. Pass restart (decide body or start action) to discard it and begin again, or resume=0 in the URL to skip resuming for that visit.

Partial answers of sensitive and secret fields are never saved, so they are asked again after a resume.

Fetch on change: call an API while the respondent fills the form

A field can call an API you configured when its value changes and fill other fields from the answer. Create a connection (Custom API (lookups) with a secret, or Apollo.io with your own key), then a data source of kind api (POST /api/v1/data-sources, integration:manage), then set lookup on the field.

json
// data source: VAT number to company (the secret is only ever put into header values)
{ "kind": "api", "name": "VAT check", "formId": "frm_1", "connectionId": "icn_1", "action": "request",
  "urlTemplate": "https://api.example.com/vat/{value}", "method": "GET",
  "headers": { "Authorization": "Bearer {secret}" } }

// field: fill company and city from the answer's paths
{ "id": "vat", "type": "short_text", "label": "VAT number",
  "lookup": { "sourceId": "dsr_1", "trigger": "blur", "minLength": 8,
    "fill": [ { "fieldId": "company", "path": "company.name" }, { "fieldId": "city", "path": "company.address.city", "overwrite": true } ] } }
Use caseSourceValue sentPaths
VAT or company number to companyCustom API, requestthe numbercompany.name, company.address.city
Postal code to cityCustom API, request (for example a postcode API)the postal codeplaces.0.place_name
Email to person and companyApollo, enrich_personthe emailname, title, company, industry, employees
Domain to companyApollo, enrich_companythe domaincompany, industry, employees, city, country

The browser sends only fieldId, sourceId and the typed value to POST /api/v1/public/forms/{slug}/lookup. The URL, method, headers and secret stay on the server. trigger is blur (default) or debounce (about 600 ms after typing stops); minLength skips short input. The call is HTTPS only, refuses private, loopback and link-local addresses after DNS, never follows redirects, times out after 5 seconds and reads at most 256 KB. Limits: 30 calls a minute per IP, 300 per form and 60 per source. Only the dotted paths listed in fill are read, only text, number and boolean values are taken, and each value must pass the target field's own validation or it is dropped. Nothing is stored; the filled fields are ordinary answers the respondent can edit.

A looked-up value never replaces something the respondent typed: it fills an empty field, or a field still holding the previous lookup's value. Set overwrite: true on a fill entry to always replace. A failed or empty lookup changes nothing. Test a source with the MCP tool test_api_data_source to see the whole answer and pick paths.

Lead enrichment

The workflow step { "type": "enrich", "id": "lead", "connectionId": "icn_apollo", "mode": "person", "input": "{email}" } looks the lead up at Apollo with the workspace's own key (mode: "company" takes a domain) and stores the result on the submission as computed.enrichment.lead. It appears under Enrichment in the response drawer and is never returned to the respondent. No match stores nothing and the run continues. Each call spends Apollo credits. The adapter is checked against Apollo's documentation with mocked requests only; it has not been run against the live service.