Skip to Content
TechnicalDevelopmentEmbeddable lead form

Embeddable lead form

A vendor pastes one line into their own website and the enquiries it collects arrive as Sustentus leads. The lead is indistinguishable from one typed into /service-leads/create: same sequential requestId, same pending/backlog initialisation, same activity trail, same downstream journey.

This is the reference for setting one up and for understanding what it does and does not do. The channel itself is described in lead intake; the other inbound channel is the inbound lead webhook.

The snippet

An admin switches the form on at Admin → Settings → Integrations, chooses which products and services it offers, and copies one of two snippets. Both are shown; they are equivalent apart from resizing.

<script src="https://platform.sustentus.com/embed/lead-form.js" data-sustentus-form="<formKey>" async ></script>

The script inserts an iframe where the tag sits and keeps it the height of its content. For a CMS that strips <script> tags, the plain frame works everywhere but cannot resize itself:

<iframe src="https://platform.sustentus.com/embed/lead-form/<formKey>" title="Request a quote" loading="lazy" style="width:100%;height:780px;border:0" ></iframe>

The form is served from Sustentus, not injected into the host page: its styling, its CAPTCHA widget and its submission all stay on our origin. Responses under /embed carry Content-Security-Policy: frame-ancestors * — being framed by sites we cannot name in advance is the point — and X-Robots-Tag: noindex, nofollow, so a vendor’s form never competes with the vendor’s own page in search.

The form key

formKey is 24 url-safe random characters minted with the config. It is not derived from the tenant id: the embed URL is public by construction, so an internal id there would be an enumeration handle for every other tenant-scoped surface.

Rotating the key from the settings card mints a new one and instantly kills every copy of the old snippet. That is the only revocation a credential living in someone else’s CMS can have, which is why the card confirms before doing it.

An unknown key, a switched-off form and a form with no configured options are one answer from both halves of the channel: an “isn’t available” page, and a 404 from the submission route with nothing written. None of the three is distinguishable from outside, so the endpoint reveals nothing about whether a tenant exists — and those three are the states a vendor actually uses to take a form down.

There is a fourth state, and the submission route no longer hides it. A form still switched on and still holding configured options, but whose every product the catalogue has since deactivated, offers nothing — call it a dead form. The hosted page still shows the same “isn’t available”, because rendering it resolves the catalogue anyway. The route now resolves only the form key up front and reads the catalogue at the offering check, behind the spam tiers (see Spam defence), so a dead form is reached by those tiers: a caller holding its key sees 422, 201, 403 or 429 from it, and a cheap refusal writes the submission row the tiers always write. Only a well-formed, Turnstile-passing body still gets the 404.

That is narrower than it sounds — the key is 24 random characters printed on the vendor’s own website, so this interrogates a key the caller already holds rather than discovering one, and what it discloses is the vendor’s catalogue state, not the tenant’s existence. Whether the state should be allowed to arise at all — a catalogue deactivation could unpublish the forms it empties, the way saving an empty selection already does — is an open question, parked in the pipeline’s triage backlog rather than settled here.

What the form offers

The vendor picks products, and services under each. The picker lists only active products that have an active service linked to them, and the public page re-filters against the catalogue as it is now — a product deactivated this morning stops being offered this afternoon with no settings edit.

Every submission is re-checked server-side against the stored offering and then against the shared product → service validator that /service-leads/create and the webhook also call. The selects the browser rendered are a suggestion; what the vendor configured is the rule.

Spam defence

Three tiers, applied cheapest first.

TierWhat it does
HoneypotA hidden field a person never sees. Filled ⇒ the ordinary success answer, and nothing written but the log row. Telling a bot it was caught teaches it to pass.
Rate limit5 submissions per 10 minutes from one client, 60 per hour for the whole form. Durable, counted from the submission log itself.
CAPTCHACloudflare Turnstile, verified server-side.

The rate limit’s client key is a SHA-256 of the caller’s address salted with the form key, so the log never stores an address and the same visitor hashes differently on two vendors’ forms.

Both windows count the same thing: submissions that reached the expensive part — a customer row, a lead, a notification. That is one rule, LEAD_FORM_COUNTED_REASONS on the submission model, and it is an allow-list, so a refusal reason added later cannot silently start spending a vendor’s allowance. Counting the cheap refusals would let anyone close a vendor’s enquiry form with junk traffic, and the 429s the guard writes would then keep it closed by themselves. On the per-client window it also let five mistyped attempts from one office behind a shared address close the form for a sixth colleague who had typed nothing wrong.

created and role-conflict both count, in both windows. They are one answer to the caller — identical status, identical body — so if only one counted, the 429 would eventually tell an anonymous caller which addresses are staff at the tenant, which is exactly what the equalised response exists to hide.

Each count reads only the rows it counts. Both windows are served end to end by their own index — tenantId_configId_clientHash_reason_createdAt and tenantId_configId_reason_createdAt, created by the 1790200000000-lead-form-count-indexes migration and declared on the submission model. Every field either filter names is in the key, reason among the equality fields, so the count never fetches a refusal to discard it. That matters on this collection in particular: it takes a write per attempt, spam included, so an index without reason made the guard slower for every legitimate visitor in proportion to the junk the form had received — most expensive under exactly the load it exists to survive.

What this does not bound is the volume of cheap refusals themselves. A row is still written for every attempt, and nothing here stops one address posting rubbish all day; the whole-form window bounds what reaches lead creation across every caller. Metering the refusal path in front of the database is an edge rule’s job, not this counter’s — the WAF rate rule specified for /api/embed/* under Firewall rules is that rule, and it refuses a burst before the function runs at all.

What the tiers themselves cost has come down. A submission is now resolved in two steps: one projected read of the form config on its unique formKey index, and the products/services fan-out only once a submission reaches the offering check. A refusal by any tier above that line therefore costs the one indexed read rather than the four-collection resolve the route used to run before it could refuse anything.

Configuring Turnstile

Two environment variables, both in turbo.json’s globalEnv:

TURNSTILE_SITE_KEY=... # rendered into the form's markup; public by design TURNSTILE_SECRET_KEY=... # verifies the token; server-side only

Set both or neither. Both halves of the tier ask the same question, so one key alone means the tier is off — the widget is not rendered and no token is required — rather than a form that challenges visitors with a token nothing verifies. Unset, the form runs on its honeypot and its rate limit, which is what makes it exercisable on a preview deployment. That state is never silent: the integrations settings card says the CAPTCHA is off, and says so differently when only one key is set, because that is a mistake rather than a decision.

What a submission creates

A lead, through createIntakeLead — the same path the webhook uses — which is leadService.create: sequential requestId, pending or backlog resolved from the customer’s state, the initial status history, the sales owner and default CSM, the manager notification and the lead-created activity entry. intakeChannel on the lead reads form.

The customer row is written immediately. No Clerk identity is created and no organisation membership is granted. The channel is anonymous — the form key is printed on a public website — so an identity minted here would be an account anyone could give themselves inside the vendor’s workspace, and submitting a third party’s address would join their existing identity to an organisation they never agreed to. The vendor invites the customer deliberately from the lead, as they already do after a spreadsheet import.

That later invite claims this row rather than colliding with it. The row owns (tenantId, email, "customer") under a unique index, so provisioning the address properly finds it already taken; ensureProvisionedFromClerk stamps the new Clerk id onto the row it finds, leaving its activation state alone. One customer document, carrying the identity — not a second document, and not the locked-out account the collision used to produce. An address whose row already carries a different Clerk id is refused instead of overwritten, since that is two people for one address.

The lead’s source is the form’s Lead source setting on the integrations settings card — one of the twelve sources the platform records (see Lead intake), stored as source on the form config. A config that has never been given one reads as sales, so existing forms keep creating Sales leads until an administrator picks another. The public payload has no source field and a posted one is dropped: how a lead is attributed is the vendor’s call, never the visitor’s.

Leads are attributed to the admin who last saved the form’s configuration. A public form has no session, and the accountable human is the one who published it.

What an admin can see

The integrations settings card lists the last 20 submissions with their outcome and, for a refusal, which tier refused it — so “nobody has filled it in” and “everything is being turned away, by this guard” are not the same picture. Submissions are kept for 30 days.

Last updated on