Diil Docs
  1. Docs
  2. The widget

Leads: send website forms straight into the CRM

Updated:

A contact form that emails someone who is on holiday is where leads go to die. With Diil, every form on your site lands as a card in the CRM — with a phone or an email, a status and someone responsible for calling back. One attribute on a <form> is enough; JavaScript and server-side options are there when you need more control.

Three ways to send a lead, pick whichever fits:

  • A form with data-crm-lead — zero JavaScript. The widget catches the submit and does the rest.
  • window.crmLead() — for React, Vue and any form you control yourself.
  • POST /marketing/lead from your server — phone orders, backend forms, integrations with other systems.

How a lead gets from your form to the CRM

In Diil a lead is a special kind of event. Every event has a name — say, contact_form — and that name has to be declared in the CRM first. Tick “This is a lead” on it, and every submission with that name becomes a card in CRM → Leads.

  1. A visitor fills in the form and presses “Send”.
  2. The widget collects the fields, sorts out who the person is (name, phone, email, message) and sends it off.
  3. The server checks the name is declared, the lead has a phone or an email, and it doesn't look like a bot.
  4. A lead card appears in the CRM — and, if you set it up, a notification in Telegram.

Two rules worth remembering from the start:

  • An undeclared name is refused. The CRM only accepts what it knows about — no surprise junk from typos.
  • Declared, but not marked as a lead? Then it is stored as a plain event, and no lead card is created.

Set up a lead type in the CRM

Two minutes of clicking, once per form type:

  1. Open Marketing → Events

    This is where every event your site may send is described.
  2. Create an event

    Give it the name your code will use: Latin letters, digits and underscores, starting with a letter — contact_form, callback_request, quote_request.
  3. Tick “This is a lead”

    Now submissions become lead cards. As the CRM hint says, the form must send a way to reply — a phone or an email.
  4. Optional: tick “This event brings revenue”

    Only then are the amount and currency of the lead stored. Without it they are quietly ignored.

Option A: a form with one attribute

Add data-crm-lead="your_event_name" to any form on a page that has the widget script. That is the whole integration. Forms that show up later — in a modal, after a route change in a single-page app — are picked up automatically.

Full example

A “request a quote” form with name, phone, email and a message, an amount for the revenue report, a “thank you” message and a custom check. Plain HTML first; React and Vue versions call crmLead() from their own submit handler.

<form id="quote" data-crm-lead="quote_request" data-crm-value="149900" data-crm-currency="EUR">
  <label>Your name <input name="name" autocomplete="name" required></label>
  <label>Phone <input name="phone" type="tel" autocomplete="tel"></label>
  <label>Email <input name="email" type="email" autocomplete="email"></label>
  <label>What do you need? <textarea name="message" rows="4"></textarea></label>

  <!-- Not one of the known fields: shows up in the lead card as is -->
  <label>Team size
    <select name="team_size">
      <option>1–5</option>
      <option>6–20</option>
      <option>20+</option>
    </select>
  </label>

  <button type="submit">Send request</button>
  <p class="form-status" role="status" hidden></p>
</form>

<script>
  const form = document.getElementById('quote');
  const status = form.querySelector('.form-status');
  const button = form.querySelector('button');

  function say(text) {
    status.textContent = text;
    status.hidden = false;
  }

  // 1. Before sending: our own check. preventDefault() = nothing is sent
  form.addEventListener('crm:lead-before', (event) => {
    const phone = form.elements.phone.value.trim();
    const email = form.elements.email.value.trim();
    if (!phone && !email) {
      event.preventDefault();
      say('Please leave a phone number or an email so we can reply.');
      return;
    }
    button.disabled = true;
  });

  // 2. After sending: the widget shows nothing, the "thank you" is ours
  form.addEventListener('crm:lead', (event) => {
    button.disabled = false;
    say(event.detail.ok
      ? 'Thank you! We will get back to you within one business day.'
      : 'Something went wrong. Please try again or give us a call.');
  });
</script>

<script src="https://widget.sitecog.com/widget.js" defer></script>

What happens when the form is submitted

  1. The widget always cancels the native submit — the page doesn't reload, and the form's action is not used.
  2. It fires crm:lead-before on the form. If any listener calls event.preventDefault(), the story ends here and nothing is sent.
  3. It collects the fields with FormData — except the ones that are never sent (passwords, card numbers and friends). Only text values count: file inputs are ignored. Several fields with the same name (a group of checkboxes) are joined with ", ".
  4. It sends the lead, protected by a one-time form pass (more on that in spam protection).
  5. It fires crm:lead with { ok: true } or { ok: false } in event.detail.
  6. If ok is true, it calls form.reset(). On failure the form is left as is, so the visitor doesn't lose what they typed.

The widget shows no UI of its own — no toast, no spinner, no “thank you”. Your site, your design, your words: listen to crm:lead and show whatever you like.

Form attributes

AttributeExampleWhat it does
data-crm-lead"quote_request"Turns the form into a lead form. The value is the event name declared in the CRM. An empty attribute means the name lead_submit — which has to be declared too.
data-crm-value"149900"Amount in minor units, an integer: 149900 is 1,499.00. Stored only if the event has “This event brings revenue”.
data-crm-currency"EUR"Currency of the amount: EUR, USD, UAH…
data-crm-ignoreno value neededGoes on a field or a wrapper (fieldset, div…), not on the form. The field — or everything inside the wrapper — is never sent. See Fields that are never sent.

Form events

Both events bubble, so you can listen on the form itself or once on document for every form on the page.

EventWhenevent.detailCancelable
crm:lead-beforeRight after submit, before anything is sent{ name } — the event nameYes: preventDefault() stops sending
crm:leadAfter the server answered{ ok } — true if the lead was acceptedNo
One listener for every lead form on the pagejs
document.addEventListener('crm:lead', (event) => {
  if (event.detail.ok) {
    event.target.closest('.modal')?.classList.add('is-thanks');
  }
});

Field names the CRM understands

A lead card has four main slots: name, phone, email and message. The widget fills them by looking at field names. Names are trimmed and lowercased, then matched exactly against this list (so Phone works, your-phone does not):

SlotField names that fill itMax length
Namename, fio, username, user_name, fullname, full_name, firstname, first_name, contact_name, client_name, имя, фио, ім'я200
Phonephone, tel, telephone, mobile, phone_number, contact_phone, телефон, тел40
Emailemail, e_mail, e-mail, mail, contact_email, почта, пошта, емейл320
Messagemessage, comment, comments, text, question, note, description, task, сообщение, комментарий, вопрос, повідомлення, коментар4000
  • First match wins. If a form has both phone and mobile, the first one in the form fills the slot.
  • Everything else is kept too. Unknown fields and second matches go to “extra” and appear in the lead card in form order. Up to 30 extra fields; keys up to 60 characters, values up to 1000.
  • A phone counts only with 7 to 20 digits. Spaces, brackets and dashes are fine; a leading + is kept.
  • A phone or an email is a must. A lead with neither is refused with no_contact — there would be no way to reply.

Fields that are never sent

A lead card is for a name, a phone and a question — not for someone's password or card number. So the widget skips some fields on purpose, even when they sit inside a lead form:

  • Password inputs — anything with type="password".
  • Anything marked data-crm-ignore — on the field itself or on any wrapper around it (fieldset, div…). Mark a wrapper and everything inside is skipped.
  • Fields with a sensitive name. The name is split into words (by _, -, ., camelCase and so on), and a field is skipped if it has card, cc, csc, cvv, cvc, iban, ssn, secret or pass as a separate word — or if password, passwd, pwd, token, csrf, xsrf, creditcard, cardnumber or ccnum appears anywhere in the name.
Field nameSent?Why
card_numbernocard is a separate word
cvvnoa sensitive word on its own
csrf_tokennocontains csrf and token
user_passwordnocontains password
discard_reasonyescard hides inside discard, but it isn't a separate word there

A few more details:

  • One name, one decision. If one field with a given name is excluded, every field with that name is excluded too.
  • Hidden inputs are sent. Handy for passing the product, the plan or the page along with the lead — as long as their name isn't a sensitive one.
  • The server double-checks. It applies the same filter on its side — for the browser routes and for POST /lead with a secret key alike — so a sensitive field never ends up in the CRM, whichever way it was sent.
What gets through and what doesn'thtml
<form data-crm-lead="signup_request">
  <input name="name">                            <!-- sent -->
  <input name="email" type="email">              <!-- sent -->
  <input name="plan" type="hidden" value="pro">  <!-- sent: hidden inputs are fine -->

  <input name="account_pass" type="password">    <!-- never sent: a password input -->
  <input name="card_number">                     <!-- never sent: "card" is a separate word -->

  <fieldset data-crm-ignore>                     <!-- nothing inside is sent -->
    <input name="internal_note">
    <input name="promo_hint">
  </fieldset>

  <button type="submit">Sign me up</button>
</form>

Option B: send leads from JavaScript

When you own the form state — React, Vue, a multi-step wizard, a chatbot — call the widget directly. Both functions appear on window once widget.js has loaded.

crmLead(name, fields, options)

const { ok } = await window.crmLead(
  'callback_request',
  { name: 'Anna', phone: '+49 30 1234567', message: 'Call me after 5 pm, please' },
  { value: 149900, currency: 'EUR' },
);
namestringrequired
Event name declared in the CRM and marked “This is a lead”. Latin letters, digits and underscores, starting with a letter (^[A-Za-z][A-Za-z0-9_]*$). A name that doesn't fit gets a warning in the console and { ok: false }.
fieldsobjectrequired
The form data as plain key–value pairs. Keys follow the same field name rules as forms: known ones fill name, phone, email and message, the rest goes to “extra”. Keys with a sensitive name are dropped just like form fields — see Fields that are never sent.
options.valueintegeroptional
Amount in minor units: 149900 = 1,499.00. Stored only for events with “This event brings revenue”.
options.currencystringoptional
Currency of the amount, e.g. EUR.

Returns a Promise that resolves to:

okboolean
true — the lead is accepted. false — it was refused or didn't get through; the reason is not reported, on purpose (see why).

crmLeadForm(form, name)

Does exactly what the data-crm-lead attribute does, but from code. Handy when the form is rendered by a third-party library and you can't add attributes to it, or when the name is decided at runtime. Returns nothing; results arrive through the same crm:lead event.

const form = document.querySelector('#newsletter-popup form');
window.crmLeadForm(form, 'newsletter_signup');

form.addEventListener('crm:lead', (e) => {
  if (e.detail.ok) form.innerHTML = '<p>You are in! Check your inbox.</p>';
});
formHTMLFormElementrequired
The form element to bind.
namestringrequired
Event name declared in the CRM, same rules as in crmLead.

TypeScript declarations

The widget is a plain script, so TypeScript doesn't know about it. Drop this into any .d.ts file:

crm-widget.d.tsts
export {};

declare global {
  interface Window {
    crmLead?: (
      name: string,
      fields: Record<string, string>,
      options?: { value?: number; currency?: string },
    ) => Promise<{ ok: boolean }>;
    crmLeadForm?: (form: HTMLFormElement, name: string) => void;
  }
}

How spam protection works

A public form is a magnet for bots. You don't need a CAPTCHA to keep them out — the widget and the server handle it together, and real visitors never notice.

A one-time form pass

Before sending, the widget gets a signed “form pass” from the server. To save time, it asks for one as soon as the visitor first focuses the form. A pass is:

  • single-use — one pass, one lead;
  • bound to your site — a pass from one site is useless on another;
  • valid for 24 hours.

Bots that blindly POST to the endpoint have no pass and are refused. And a double click or a re-submit with the same pass returns { ok: true }, but the lead is stored once — no duplicate cards from impatient fingers.

The honeypot

The hidden company_site field from above. A human can't see it, so it stays empty. A bot that fills in every field it finds gives itself away, and the lead is refused.

“Suspicious” leads

Some leads look odd but might be real. Those are accepted and get a Suspicious label in the CRM, so a manager can take a look before calling:

  • the form was filled in under 3 seconds — faster than humanly possible;
  • the form was open for more than 30 minutes before sending;
  • more than 5 leads came from one IP address within an hour.

“Spam” is a status a person sets by hand in the CRM. Nothing is marked as spam automatically, so no real customer is silently thrown away.

The IP address in the lead card is shown truncated — IPv4 loses its last number (203.0.113.57 → 203.0.113.0), IPv6 is cut to /48 — the same way Diil stores IPs everywhere.

Why the browser only gets ok: true or false

Telling a bot “refused: honeypot filled” is a free lesson in how to get through. So from the browser every refusal looks the same — { ok: false }, no reason given. Debugging your own form? The troubleshooting checklist covers every case, and the server route does tell you what went wrong, because it is protected by a secret key.

Under the hood: the browser request

For the curious — you never have to build this yourself. The widget gets a pass from POST https://back.sitecog.com/marketing/fk (answer: {"pass":"…"}), then sends the lead:

What the widget sendshttp
POST https://back.sitecog.com/marketing/f
Content-Type: application/json

{
  "n": "contact_form",
  "pass": "…",
  "fields": {
    "name": "Anna",
    "email": "anna@example.com",
    "message": "Hi!",
    "company_site": ""
  },
  "val": 149900,
  "cur": "EUR",
  "u": "https://shop.example/contacts",
  "vid": "…",
  "sid": "…"
}

The answer is { "ok": true } or { "ok": false }. vid and sid are the visitor and session ids, so the lead is linked to the ad channel the visitor came from — see Events & analytics.

Send leads from your server

Not every lead starts in a browser. Use the server route when:

  • a manager takes an order by phone and enters it in your back office;
  • your form is processed on the backend (a PHP handler, a Next.js server action) and you'd rather not depend on the widget;
  • leads come from another system: a marketplace, a booking service, a bot.
POST https://back.sitecog.com/marketing/lead
x-event-key: sk_…
Content-Type: application/json

Secret keys

The server route is authenticated with a secret key. Create one in CRM → Marketing → Events → Secret keys → Issue a key. A key looks like sk_ + 48 hex characters; a site can have up to 5 active keys, and any of them can be revoked. Give each key a name that says where it lives (“payment server”, “phone orders”) — future you will be grateful when it's time to revoke one.

Example request

curl https://back.sitecog.com/marketing/lead \
  -H "x-event-key: sk_3f9a1c7e5b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a1c3e5b7d" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "phone_order",
    "event_id": "order-1024",
    "fields": {
      "name": "Anna Schmidt",
      "phone": "+49 30 1234567",
      "message": "Two pairs of Air 3, graphite. Delivery to the office."
    },
    "value": 149900,
    "currency": "EUR",
    "url": "https://shop.example/contacts"
  }'

Request body

x-event-keyheaderstringrequired
Secret key sk_… from the CRM. Missing or revoked → 401 invalid_key.
namestringrequired
Event name declared in the CRM and marked “This is a lead”. Short alias: n.
fieldsobjectrequired
The lead itself: name, phone, email, message and anything else, by the same field name rules. Must contain a phone or an email. Always nest contact data here — keys put at the root of the body get mixed into the lead. Fields with sensitive names are filtered out here too, exactly as in the browser (the rules).
event_idstringoptional
Your id for this lead, up to 128 characters — an order number, a ticket id. Makes the request idempotent. Alias: id.
visitor_idstringoptional
The visitor id from the browser (crm_vid). Alias: vid. See attribution.
session_idstringoptional
The session id from the browser (crm_sid). With it, the lead inherits the ad channel and UTM tags of that session. Alias: sid.
valueintegeroptional
Amount in minor units: 149900 = 1,499.00. Stored only for events with “This event brings revenue”. Alias: val.
currencystringoptional
Currency of the amount, e.g. EUR. Alias: cur.
urlstringoptional
The page the lead is about or came from. Alias: u.

Response

200 OKjson
{ "ok": true, "duplicate": false }
okboolean
Always true on a 200. Problems come back as 4xx with a message — see errors.
duplicateboolean
true if a lead with this event_id already exists. Nothing new was stored, and that's fine.

Unlike the browser, the server route doesn't use form passes or timing checks — the secret key is proof enough. The honeypot still applies: a non-empty company_site in fields gets 400 rejected.

Idempotency: retry without fear

Networks fail at the worst moments. Did the request go through or not? With event_id you don't have to know: send it again. A repeat returns {"ok":true,"duplicate":true} and the lead is not stored twice.

Same event_id, second timejson
{ "ok": true, "duplicate": true }

Attribution: which ad brought the lead

Leads from the widget are linked to the visitor automatically. A server lead knows nothing about the browser — unless you tell it. The widget keeps the visitor id in localStorage under crm_vid and the session id under crm_sid. Send them to your backend along with the form or order, and pass them on:

In the browser, at checkoutjs
const crm = {
  vid: localStorage.getItem('crm_vid'),
  sid: localStorage.getItem('crm_sid'),
};

await fetch('/api/orders', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ ...order, crm }),
});
// …and on the server: visitor_id: body.crm.vid, session_id: body.crm.sid

With session_id, the lead inherits the ad channel and UTM tags of that session's first page view, so it shows up in reports next to the campaign that earned it. More in Events & analytics.

Errors

From the browser, crmLead() and the crm:lead event only ever say ok: false. The server route answers with a status and a message:

StatusBodyWhat happened
400{"message":"unknown_event","name":"phone_order"}No event with this name in the CRM. Declare it in Marketing → Events (check the spelling and case).
400{"message":"invalid_event_name"}The name has characters outside Latin letters, digits and underscores, or doesn't start with a letter.
400{"message":"no_contact"}Neither a phone (7–20 digits) nor an email in fields.
400{"message":"invalid_body"}The body is not a JSON object of the expected shape.
400{"message":"rejected"}The honeypot field company_site is filled in.
401{"message":"invalid_key"}Missing, malformed or revoked x-event-key.
413—The body is bigger than 8 KB.
429{"message":"rate_limit_exceeded"}Too many requests this minute. Wait for the next one.

Browser requests can also get 400 unknown_origin or 400 unknown_domain when the page's domain is not a site in the CRM — you'll see them in the Network tab, while the code still gets ok: false.

Limits

WhatLimit
Requests per IP (all marketing routes together)120 per minute
Requests per site6000 per minute
Form passes per IP20 per minute
Request body8 KB
Name / phone / email / message200 / 40 / 320 / 4000 characters
Extra fieldsup to 30; key up to 60, value up to 1000 characters
Phone7–20 digits to count as a contact
event_idup to 128 characters
Secret keysup to 5 active per site

Windows are fixed minutes: after a 429, wait until the next minute starts. A real visitor will never get close; a script hammering your form will.

Troubleshooting

The lead didn't arrive

Go down the list — it's almost always one of these:

  • The name isn't declared. Marketing → Events must have an event with exactly this name, case included. An empty data-crm-lead means lead_submit — declare that one.
  • “This is a lead” isn't ticked. Then the submission is stored as a plain event: look for it in the event stats, not in Leads.
  • No phone and no email. Or they are there, but under names the CRM doesn't know (your-phone, contact[email]) and ended up in “extra”. Rename them — see the field table. A phone with fewer than 7 digits doesn't count either.
  • The data was in a file input. Files are ignored — the widget sends text only.
  • A real field is called company_site. That's the honeypot name; the lead is treated as a bot.
  • Your own crm:lead-before listener called preventDefault() — maybe not when you expected.
  • The widget isn't on this page. Without widget.js the attribute does nothing and the form submits the old-fashioned way (or not at all).
  • A Content Security Policy blocks it. If your site has a CSP, it needs script-src https://widget.sitecog.com and connect-src https://back.sitecog.com. The browser console will say so in red.
  • The domain isn't a site in the CRM. The site is recognized by the page's origin (www. is stripped). Testing on localhost or a staging domain that isn't added to the CRM gives unknown_domain in the Network tab.
  • You were testing a lot. 20 form passes per minute per IP is plenty for people but not for frantic clicking. Wait a minute.

A field didn't arrive

The lead is there, but one of the fields isn't. Usually it was skipped on purpose:

  • Its name looks sensitive. Check it against the sensitive-name rules: promo_pass has pass as a separate word and is never sent. Rename it to something like promo_code and it comes through.
  • It sits under data-crm-ignore. The attribute may be on a wrapper a few levels up — a fieldset or a div you forgot about.
  • Another field with the same name is excluded. Then all fields with that name are skipped.
  • It is a file input. Files are ignored anyway — the widget sends text only.

The amount isn't shown

Tick “This event brings revenue” on the event type. Also check the amount is an integer in minor units: 1499.00 should be sent as 149900.

Notifications in Telegram

A lead nobody sees is a lead lost. In CRM → Leads settings you can connect a Telegram bot: enter the bot token, turn notifications on, decide whether suspicious leads should be sent too, and set quiet hours so the night shift of bots doesn't wake up your sales team.

Notifications are formatted with HTML, and everything the visitor typed is escaped before it gets there. A “name” like <a href="…">Click here</a> arrives as plain text: a visitor can't slip a link into your team's chat or break the formatting. Long fields are trimmed, so a novel in the message box doesn't turn into a wall of text — the full lead is always in the CRM.

Deleting a person's data on request

“Please delete everything you have about me” is a normal request, and answering it shouldn't mean a week of digging through tables. The CRM has two tools for it — no code on your side.

From a lead or a chat

A lead card and a chat card both have “Delete this visitor's data”. Before anything is deleted, the CRM shows what exactly will go:

  • the visitor profile and analytics — page views and events;
  • chats and attachments — the number of chats, messages and files;
  • leads — only if you tick “Also delete this visitor's leads”. Off by default: a lead is often a deal in progress, and the decision is yours.

A lead sent from your server without visitor_id isn't linked to a visitor, so only the lead itself can be deleted there.

By email or phone

Usually the request comes as an email: “I'm anna@example.com, forget me”. Go to CRM → Settings → “Data deletion requests”, enter the email or the phone the person gave, and the CRM finds their leads, chats and account on your site. One button deletes everything found; the visit analytics of the related visitors can go too.

  • Rights. Deleting needs the same rights as editing those sections. A manager who can only view chats sees what was found but can't delete it.
  • Audit log. Every deletion is written to the activity log — without the person's email or phone, otherwise the log would keep exactly what was asked to be forgotten.
  • No undo. Deleted means deleted. Check the counts before you confirm.