Steeren/Forms, gating & lead capturelive from the platform← site
Capture & manage leads

Forms, gating & lead capture

This is the capture-and-convert layer: HTML forms on your site, the leads they produce, content that's withheld until a visitor identifies themselves, and the path each submission takes into the CRM.

There are three distinct submission paths in the codebase, and they behave differently. Keep them straight:

Path What it is Tenant resolved by Lands in
Standalone form (cms.forms row, has a slug) A reusable form you define once and reference by slug explicit X-Tenant-Id if present, else visitor host; slug alone is the fallback cms.form_submissions (with form_id) → CRM
Content gate A post is locked; the gate form is a standalone form wired to the post via gated_form_id same as standalone same as standalone, plus post attribution
Inline contact form (a contact_form post component) A form embedded in a single article, schema lives on the component visitor host only cms.form_submissions (with post_id/component_id) → CRM

The first two share the same machinery (submitForm in submissions/service.js). The third is a separate handler (contactForm.service.js) with its own spam guards and a different storage shape. This article covers all three, then the relay that connects your site to the platform, then gating.

Standalone forms

A form is a row in cms.forms: a slug (unique per tenant), a display name, a schema describing its fields, an on_submit config, and an enabled flag. You manage them through the authenticated CMS routes (POST /forms, PATCH /forms/:id, DELETE /forms/:id). There's no form-builder UI; you define the shape as JSON.

Field types

schema.fields is an array. Each field has a key, a label, a type, and an optional required flag:

type Validated as Notes
text string
email string matching a basic email regex
phone string no format check beyond "is a string"
textarea string
select string options array (strings, or {label, value} objects)
checkbox boolean must be a real true/false, not "on"
number finite JSON number a numeric string like "42" fails

Validation runs server-side on every submission against the form's own schema.fields. A required field that's empty fails (required); a present field of the wrong type fails (invalid_email, must_be_string, must_be_boolean, must_be_number). Unknown field types pass through without validation.

On failure the submit returns 400 with the errors nested under error.details.fields — the standard error envelope, not a top-level fields:

{
  "error": {
    "code": "bad_request",
    "message": "validation failed",
    "details": {
      "fields": [
        { "key": "email", "message": "invalid_email" }
      ]
    }
  }
}

[!NOTE] Field types are validated, but the platform does not yet do anything richer — no regex constraints, no min/max on number, no length caps on text. The name / company / message columns are unbounded text and email is citext, so nothing truncates a long value. Validate hard on your own side too if you need it.

on_submit

Three optional knobs control what the visitor's browser does after a successful submit:

Field Effect
redirect_url returned to the client so it can navigate after submit
success_message returned to the client to show inline
unlock_post_id mints an unlock token in the response (see the token dead-end)

These are returned in the submit response; the platform doesn't perform the redirect itself — your form code reads redirect_url / success_message and acts on them.

Submission storage

A successful submit writes one row to cms.form_submissions. Five fields are pulled into dedicated columns — email, name, phone, company, message — and any other field declared in the schema goes into a custom JSON bag. Two wrinkles worth knowing:

  • Fields you send that aren't declared in the schema are dropped, not stored.
  • A field whose key is a baseline column (email, name, phone, company, message) is never duplicated into custom, even when it's also declared in the schema. So a declared company field lands in the company column and nowhere else.

The row also captures source_url (the Referer), user_agent, and the raw visitor ip (an inet column — this path stores the raw IP, unlike the contact-form path below).

[!WARNING] The default gating form (see below) declares its company field as company_name, not company. Because company_name isn't a baseline column, its value lands in the custom JSON bag, not the dedicated company column. If you build reporting that reads the company column, default-gate leads will look like they have no company. Either rename the field to company in your own gate form, or read custom.company_name.

You read submissions back through the authenticated member routes:

  • GET /submissions?form_id=&limit=&offset= — list, newest first.
  • GET /submissions/summary?from=&to=&form_id= — aggregate totals plus per-form and per-post breakdowns over a window (default last 30 days). This mirrors the CTA-click stats shape so the analytics dashboard can show both conversion types together. Note the breakdowns are capped at the top 50 forms and top 50 posts; high-cardinality tenants are truncated, and submissions with no form_id bucket under null.

The same-origin relay (why the browser usually can't call the gateway directly)

Your site generally doesn't submit forms straight to the Nukipa gateway from the browser. It posts to a route on your own origin, and that route forwards the call server-side. Two reasons:

  1. CORS. A browser fetch from yoursite.com to the Nukipa gateway is a cross-origin request. Rather than maintain a CORS allow-list of every customer domain on the gateway, sites relay through their own backend, where same-origin rules don't apply.
  2. Tenant resolution. The gateway figures out which tenant a request belongs to from the host the visitor used, forwarded as X-Forwarded-Host. The relay sets this reliably server-side. A naked browser call would send the browser's own Host, which works on a custom domain but is fragile across previews, proxies, and SSR.

In the Next.js starter, the regular contact form relays through a route under /api/. For example, /api/contact calls the SDK server-side:

// src/app/api/contact/route.ts — runs on YOUR server, not the browser
export async function POST(req: NextRequest) {
  const body = await req.json();
  const client = await getNukipaClient();      // host resolved server-side
  await client.submitForm('contact', body);    // → gateway → CMS
  return NextResponse.json({ ok: true });
}

The browser component just posts JSON to /api/contact. The SDK's submitForm(slug, body) is what actually hits POST /public/v1/forms/<slug>/submit on the gateway.

[!WARNING] The SDK only forwards the visitor's IP / User-Agent / Referer when the client factory supplies getIp / getUserAgent / getReferer callbacks. The Next.js starter's server factory getNukipaClient() wires only getHost. So on the contact-form relay path the stored ip, user_agent, and source_url are the relay/gateway egress values, not the visitor's. (The starter's edge middleware factory getMiddlewareClient does wire IP/UA/referer, but that's for visit pings, not form submits.) If you need real visitor attribution on submissions, add those callbacks to your client factory.

A form slug like contact is not globally unique — it's unique per tenant. On the gateway path the tenant is resolved from the forwarded host. If the host doesn't resolve to a tenant and the slug exists for more than one tenant, the CMS refuses the ambiguous slug with a 400 ("form slug is ambiguous") rather than guessing. A slug that's globally unique still resolves without host context. This is why the relay should forward the host.

The gateway → CMS hop and rate limiting

The gateway forwards POST /public/v1/forms/:slug/submit to the CMS. That public submit route sits behind a single shared rate-limit bucket — ~2000/min by default, tunable via PUBLIC_SUBMIT_RATE_LIMIT without a deploy.

[!NOTE] The limiter keys on req.ip, but the gateway runs with trust proxy: 1, so for server-proxied traffic req.ip is the relay/gateway egress, not the visitor — every tenant's submits collapse onto a shared bucket. The route's own comment is explicit that a per-IP counter is the wrong primitive here: it's a coarse safety cap, not real abuse protection. Real protection is deferred to the edge (Cloudflare / Vercel WAF). There is no separate per-slug limiter on the standalone submit path.

Submissions become CRM contacts (standalone path)

Every standalone submission with an email is mirrored to the CRM as a lead, best-effort. The CRM call happens after the form_submissions row is written, so a CRM failure never loses the submission or surfaces an error to the visitor — the durable record is the submission row; the CRM mirror is logged on failure and skipped.

What's mapped:

  • Name is split on whitespace into first_name / last_name on a best-effort basis ("Ada Lovelace"Ada / Lovelace).
  • Company is sent as a string; the CRM resolves it to a crm.companies row server-side (find-or-create, case-insensitive) and links it.
  • source records where the lead came from. For an unattached form it's form:<slug>. For a form acting as a post's gate it's blog_form:<post-slug> — so the CRM shows the article the visitor unlocked, not the generic form. The originating form_id is also recorded as source_form_id.
  • custom carries the extra schema fields; for a gated submit it additionally gets post_id and post_slug.

After the lead is created, the submission row is updated with crm_contact_id so the two are linked. (The standalone path does not patch a status column — that row keeps the table default received; the crm_synced / crm_failed statuses are set only by the inline contact-form path below.)

Standalone submit failure modes

Beyond the 400 validation failed above, the standalone submit can return:

Outcome When
404 form not found the slug doesn't resolve to a form for the tenant
403 form is disabled the form exists but enabled is false
400 ambiguous slug the host didn't resolve and the slug is used by more than one tenant
429 too_many the shared submit rate-limit bucket is exhausted

A disabled form is also hidden from GET /public/forms/:slug, which 404s it (same surface as a missing slug) so a disabled form doesn't leak its presence.

Inline contact-form components

A post can embed a contact_form component — schema inline on the component, no cms.forms row. These submit through a different endpoint (POST /public/v1/posts/:postId/contact-form-submissions) and a separate handler with extra spam protection:

  • a honeypot field (hp_field) — if filled, the row is stored as status='spam' and the response is a normal 200 so the bot can't tell it tripped;
  • an RFC-ish email validation — an invalid email is rejected with 400 before insert;
  • a "every field identical" heuristic for fill-all bots (also stored as spam, 200);
  • per-IP (5/hour/post) and per-email (10/day/tenant) rate limits, counted directly against cms.form_submissions; a breach throws 429;
  • the post must be published (current_version_id set) or the submit 404s — unpublished / preview posts can't take submissions.

IPs on this path are hashed with a per-tenant salt and stored in ip_hash; the raw IP is never persisted (the standalone path above stores the raw ip instead — an existing inconsistency between the two handlers, not a feature). On success the row's status is patched to crm_synced or, if the CRM call fails, crm_failed, and the handler returns { data: { id, status } } so the caller sees that status. Leads from this path get source: 'blog_form'.

Content gating

Gating withholds the body of a published post until a visitor identifies themselves through a form. It's the lead-magnet pattern applied to articles you already have.

How a post becomes gated

A post is gated when its gated_form_id column points at a cms.forms row. The simplest way to turn it on is the MCP tool:

cms_enable_gating(post_id)

This is idempotent and does two things on first use:

  1. Lazily creates a default form for the tenant — slug default-lead-capture, with fields keyed name / email / company_name / message — and reuses it on every later gate. (You can switch a post to a different form afterwards via cms_update_post.)
  2. Sets gated_form_id on the post and a default gate_after_paragraph of 2.

[!WARNING] cms_enable_gating accepts a gate_after_paragraph argument and forwards it in the request body, but the underlying POST /posts/:id/enable-gating route ignores the body — it always uses the value already on the post, or the default of 2. So the tool isn't at fault; the route silently swallows the argument. To set a custom cutoff, gate the post first, then set gate_after_paragraph with cms_update_post. (You can also set gated_form_id + gate_after_paragraph directly through cms_update_post in one call and skip enable_gating entirely.)

To remove gating, cms_disable_gating(post_id) clears both columns. Existing submissions stay where they are — disabling only severs the post↔form link.

[!NOTE] Gating only affects the public render. Enabling it touches the live post row, not a published snapshot, so the dashboard reflects it immediately, and anonymous readers see the gate as soon as the form link is set.

What a locked response looks like

The public read (GET /public/v1/posts/:slug) checks whether the visitor has already unlocked. If not, it returns a locked payload:

  • body is truncated to the first gate_after_paragraph markdown paragraphs. 0 (or null/negative/non-numeric) means excerpt only — no body paragraphs at all.
  • components and sources are cleared (they belong to the locked half).
  • is_gated: true is set, along with the gate's form metadata: gated_form_slug, gated_form_name, gated_form_fields, gate_after_paragraph — enough for your site to render the gate form in one round-trip.
  • The response carries Cache-Control: private, no-store, because different visitors see different bodies.

Your site renders the teaser, then the gate form when is_gated is true. In the Next.js starter that's a <GateForm> that reads gated_form_fields and posts to the gate's slug.

How unlock works

"Unlocked" is intentionally simple. On a gated read the CMS asks: does any row exist in cms.form_submissions for this (tenant, gated_form_id) whose email or ip_hash matches this visitor? If yes, the full body is returned. Either match unlocks. There is no expiry — once a visitor has handed over their email, re-gating them is friction, not security.

The check is fail-closed: the gate stays locked when there's no formId, when neither an email nor an ip_hash is present, or when the lookup hits any DB error.

The visitor's identity reaches the CMS as request headers — X-Visitor-Email and X-Visitor-IP-Hash — which the gateway passes through verbatim to the CMS public read. The CMS only consumes resolved IDs; it does not own the cookie or the email→IP mapping. Whatever renders your site does.

Where gating actually persists, and where it doesn't

This is the part that differs by surface. The platform ships two reference implementations, and they take opposite approaches.

The managed app is Nuxt (apps/public), and it closes the loop for you:

  1. The visitor submits the gate form. The gate form posts through the relay /api/forms, which forwards to the gateway and, when the submission carries an email, sets a cookie (nk_lead_email).
  2. On every later SSR fetch, apps/public reads that cookie and forwards it as X-Visitor-Email.
  3. The CMS matches the email against the submission row → the body is unlocked on the very next render.

The Next.js starter does not persist unlock at all out of the box:

[!WARNING] In the Next.js starter, <GateForm> posts the unlock submission directly from the browser to the gateway (fetch(GATEWAY + '/public/v1/forms/<slug>/submit')) — not through a relay — and then calls window.location.reload(). Nothing wires a cookie or forwards X-Visitor-Email: the server factory getNukipaClient() sets no visitor-email header, and the blog page never reads or forwards an unlock identity. So the reload re-fetches the post with no X-Visitor-Email, visitorHasUnlocked returns false, and the post re-locks immediately — including the current page. Gating does not "stick" on the shipped starter.

To make unlock work on a self-hosted Next.js (or any custom) site, reproduce what apps/public does: on submit, store the email (a cookie is fine); on every post fetch, forward it as X-Visitor-Email. The CMS does the rest. The browser-direct gate post is a property of the starter; relayed-plus-cookie is a property of the managed Nuxt app.

Two related limitations:

  • IP-hash unlock isn't wired end-to-end. The CMS will match on ip_hash, but the managed public app only forwards X-Visitor-Email today. The deeper reason is that the per-tenant salt used to compute ip_hash isn't shared with the public app, so it can't produce a matching hash. Email is the only identity that reliably unlocks; a visitor who clears cookies has to re-submit.
  • No platform unlock token for a custom site — see below.

The unlock_post_id token is a dead end

If a standalone form's on_submit.unlock_post_id is set, the submit response includes a signed unlock_token (HMAC-SHA256 over a base64url payload, exp = +24h, signed with INTERNAL_JWT_SECRET). Nothing consumes it. The endpoint that would verify the token and return the gated body was never built — it's an explicit TODO in submissions/service.js. The live gating path is gated_form_id + email-match, full stop. Treat unlock_token as reserved/inert; don't build against it.

Known gaps

  • Custom gate fields don't render on the managed app. A gate form's schema.fields are passed through to the managed renderer, but it still shows the fixed name/email/company/message inputs; custom fields won't appear until it reads the field list dynamically. (The Next.js starter's <GateForm> does render gated_form_fields, so it's ahead of the managed renderer here.)
  • No gate-conversion analytics. There's no "X% of gated views convert" surface. You can count submissions per post via GET /submissions/summary, but view→unlock rate isn't computed.
  • No unlock expiry. By design — an unlocked email is unlocked forever.

FAQ

Do I need a cms.forms row for a gate? Yes — the gate is a standard standalone form, referenced by the post's gated_form_id. cms_enable_gating creates a shared default-lead-capture form the first time so you don't have to.

Can one form gate multiple posts? Yes. The default form is reused across every gated post in the tenant. Attribution still resolves to the right article: the gated submit threads the post's post_id (verified to belong to the tenant and to be gated by this form before it's trusted), and falls back to a deterministic ORDER BY id gated-post lookup when no post_id is supplied.

Why does my contact form submit to /api/contact instead of the gateway? That's the same-origin relay — it avoids CORS and lets the host be resolved server-side for tenant routing. Note the Next.js starter's gate form is the exception: it posts straight to the gateway, and on that surface unlock persistence is on you.

A submission came back 200 but no CRM contact appeared. CRM mirroring is best-effort and only runs when an email is present — the submission row is always written first. If the email was missing or the CRM call failed, the form_submissions row still exists; check it via GET /submissions.

Where do leads from inline contact forms differ from standalone forms? Different endpoint, different handler, extra spam guards (honeypot, fill-all heuristic, per-IP/email rate limits), hashed IPs (ip_hash, not raw ip), a patched status (crm_synced / crm_failed), and source: 'blog_form'. Standalone forms store the raw IP, leave status at the received default, and use form:<slug> / blog_form:<post-slug>.

Served live from the platform · /docs/forms-gating-lead-capture