Skip to content

Web forms

A web form is a form you build in the CRM and embed on your own website. Somebody fills it in with no account and no login, and a lead appears in your org with the owner, source and tags the form was configured with.

Everything below lives in backend/webforms/.

Building one

/settings/web-forms in the web app, Settings → Web forms on the phone, or POST /api/webforms/ directly.

Reading the list is open to every member of the org. Creating, editing, publishing and deleting are admin-only. That split is deliberate: a published form is an endpoint anyone on the internet can post to and every accepted post writes a lead into your org, so creating one is closer to minting a credential than to editing a record.

A form is an ordered list of fields. Each field writes into either one lead column or one of your org's lead custom field definitions, never both. The lead columns a form may collect are a fixed whitelist:

salutation, first_name, last_name, email, phone, company_name, job_title, website, title, description, city, state, country, postcode, industry.

Owner, pipeline stage, probability and deal value are deliberately absent. A form is filled in by an anonymous stranger, so what they can reach has to be a decision somebody made rather than whatever the model happens to expose.

Publishing

POST /api/webforms/<id>/publish/. A form accepts nothing until it is published, and publishing validates the form's shape rather than flipping a flag:

  • An email field is required. It is what lets a repeat submission update the lead you already hold instead of colliding with the unique constraint on (lower(email), org).
  • A redirect-on-success form needs a redirect URL, and it must be http or https. The embed navigates to it, so a javascript: value there would be stored XSS on your own site.
  • Publishing an already-published form is a 400, not a silent no-op.

POST /api/webforms/<id>/unpublish/ stops it. Unpublish the moment you take the snippet off your site: removing the embed from a page is not the same as closing the endpoint.

Embedding

Both snippets come back on the form's detail response, built server-side because they need the API's own absolute URL.

iframe, self-contained and inheriting nothing from your page:

<iframe src="https://crm.example.com/api/public/forms/<org_id>/<form_id>/embed/"
        style="width:100%;border:0" height="500" title="Contact us"></iframe>

Script, rendering into a div so it picks up your own layout:

<div id="bottlecrm-webform-<form_id>"></div>
<script src="https://crm.example.com/api/public/forms/<org_id>/<form_id>/embed.js" async></script>

Neither carries a credential. The org id in the path selects the tenant and is not treated as a secret; the form row is filtered on it, so a mismatched pair answers 404 exactly like a form that does not exist.

What happens on submit

  • A new address creates a lead with status="assigned", the form's lead source, its assignee and its tags.
  • A repeat address merges, matched case-insensitively within the org. The merge fills blanks only and never overwrites. Anyone who knows a prospect's address can post your public form, so an overwrite would let a stranger rewrite that person's record. Assignment, status, source and the pipeline columns are never touched.
  • The message becomes a comment on the lead rather than replacing the previous one.
  • The assignee and the notify list are emailed once per accepted submission. Rejected submissions notify nobody.
  • Every submission is stored, accepted or rejected, which is what makes spam review and an honest conversion rate possible.

Spam controls

Three are always on:

  • A honeypot field, off-screen rather than display:none so it still looks fillable to a bot that skips hidden inputs. A submission that fills it writes no lead and gets the same success response a real one gets.
  • Two rate limits: per client IP per form (WEBFORM_THROTTLE_IP, default 10/hour) and per form across everybody (WEBFORM_THROTTLE_GLOBAL, default 200/day). The second is the one that matters, since X-Forwarded-For is caller-controlled and rotating it defeats the first.
  • Disposable address rejection, on by default per form.

Cloudflare Turnstile is optional and off by default. It fails closed: a timeout, a connection error or a missing secret all refuse the submission, because failing open would mean an attacker's first move is making Cloudflare unreachable. The secret is write-only and never returned by the API.

Set CACHE_URL in production. Throttle counters live in Django's default cache. Without it that is a per-process LocMemCache, so with N workers the effective limit is roughly N times the configured one and resets on every restart. Point it at the Redis you already run for Celery.

Origin restrictions limit which sites may frame the form or post to it. Understand what that is: it is browser-enforced, like CORS, so it stops another website from mounting your form and is not a defence against a script, which sets those headers itself. The rate limits and the captcha are what apply there.

Analytics

GET /api/webforms/<id>/analytics/ returns a fixed trailing 30 days of views, submissions, spam and a conversion rate. Views are counted when an embed renders; submissions are counted from the submission rows, so each number has one source of truth. The series is zero-filled, so a quiet day is a real zero rather than a gap in the chart.

The settings hub flags a published form that has heard nothing in a month. The usual cause is the snippet coming off the page it was pasted onto, which nothing else would tell you.

Not built

  • Conditional fields (show B only when A was answered a given way).
  • Multi-step forms.

Both were scoped out of the first release rather than half-built. A form is a flat ordered list of fields.

The older endpoint

POST /api/leads/create-from-site/ is the original web-to-lead endpoint. It still works, unchanged request and response, and now shares the same write path. Prefer the public form endpoint for anything new: create-from-site requires an authenticated caller with org context, so it was never usable from a static page without putting a credential in it, and it has no origin restriction, no rate limit and no captcha.