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 or a support ticket appears in your org with the owner and tags the form was configured with. Which of the two a form creates is chosen when the form is made.
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.
Ticket forms
From django-crm 1.10.0 a form can create a ticket instead of a lead. You choose the target when you create the form, on the web or the phone. A ticket form collects from a fixed list too:
email, first_name, last_name, phone, company_name, name (the subject) and description (the message).
Priority, type, status and assignment are not on that list. They are the form's own settings (a priority and an optional type, set on the form) or routing's decision, never the visitor's: a stranger who could pick Urgent would.
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. On a lead form it is what lets a repeat submission update the lead you already hold instead of colliding with the unique constraint on
(lower(email), org). On a ticket form it is how the sender is matched to a contact. - A redirect-on-success form needs a redirect URL, and it must be
httporhttps. The embed navigates to it, so ajavascript: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.
Assigning new leads
From django-crm 1.13.0 a lead form can hand its new leads out in turn instead of giving them all to one person. On the form's settings page, on the web or the phone, Assign new leads is either To one person or Rotate between members. With rotation:
- Rotate between lists the members who take turns. Each new lead goes to the next one, in a fixed order, and the page shows who received the last one. Adding or removing a member does not skip or repeat anybody's turn.
- Most open leads per member is optional. A member already holding that many open leads (active, and neither converted nor closed) is passed over until one of theirs is converted or closed. The count covers every lead they hold in the org, not only this form's.
- Deactivated members are skipped. If nobody can take the lead, because everyone is deactivated or at the cap, it is created unassigned and the notify list is still emailed.
- Only new leads rotate. A repeat submission from the same address updates the lead you already hold and keeps its owner.
Rotation is for lead forms only; a ticket form's tickets are assigned by your routing rules. Like every form setting it is admin-only, and it needs at least one member. Switching back to one person restores the person the form had before.
In the API these are assignment_mode (person or rotation), rotation_members (profile ids),
rotation_cap (at least 1, or null for no cap) and the read-only rotation_last_assigned.
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 (or the member whose turn it is, on a rotation form) 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 notify list and whoever the lead belongs to are emailed, in one message, once per accepted submission: the member a rotation picked, or the existing owner when the submission updated a lead somebody already holds. Rejected submissions notify nobody.
- Every submission is stored, accepted or rejected, which is what makes spam review and an honest conversion rate possible.
On a ticket form, each accepted submission opens a new ticket:
- Status New, with the form's priority and type, its assignee if it has one, and its tags. The subject is cut to the ticket name's 64 characters rather than refused.
- The sender is matched to a contact by email, case-insensitively within your org. If there is no match, a contact is created from the name, phone and company on the form. An existing contact is never edited, for the same reason a lead is never overwritten.
- Routing and SLA apply exactly as they do for a ticket that arrives by email: routing rules run once the form's tags and contact are attached (so a rule on the sender's domain or on a tag matches), and the SLA targets are set from the priority.
- The assignee and the notify list are emailed, with a link to the new ticket.
Spam controls
Three are always on:
- A honeypot field, off-screen rather than
display:noneso 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, default10/hour) and per form across everybody (WEBFORM_THROTTLE_GLOBAL, default200/day). The second is the one that matters: the first keys on the client address, and a sender with many addresses gets many buckets. - 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_URLin production. Throttle counters live in Django's default cache. Without it that is a per-processLocMemCache, 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, so it rotates too when the form behind it is set to rotation. 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.