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/leadfrom 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.
- A visitor fills in the form and presses “Send”.
- The widget collects the fields, sorts out who the person is (name, phone, email, message) and sends it off.
- The server checks the name is declared, the lead has a phone or an email, and it doesn't look like a bot.
- 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:
Open Marketing → Events
This is where every event your site may send is described.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.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.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>'use client';
import { useState, type FormEvent } from 'react';
const EMPTY = { name: '', phone: '', email: '', message: '' };
export function QuoteForm() {
const [form, setForm] = useState(EMPTY);
const [status, setStatus] = useState<'idle' | 'sending' | 'done' | 'error'>('idle');
const set = (key: keyof typeof EMPTY) => (e: { target: { value: string } }) =>
setForm((prev) => ({ ...prev, [key]: e.target.value }));
async function onSubmit(e: FormEvent) {
e.preventDefault();
if (!form.phone.trim() && !form.email.trim()) {
setStatus('error');
return;
}
setStatus('sending');
// crmLead never throws; it is undefined only if widget.js is not on the page
const res = await window.crmLead?.('quote_request', form, { value: 149900, currency: 'EUR' });
if (res?.ok) {
setForm(EMPTY);
setStatus('done');
} else {
setStatus('error');
}
}
if (status === 'done') return <p>Thank you! We will get back to you within one business day.</p>;
// No data-crm-lead here: this form sends through crmLead itself
return (
<form onSubmit={onSubmit}>
<input value={form.name} onChange={set('name')} placeholder="Your name" required />
<input value={form.phone} onChange={set('phone')} type="tel" placeholder="Phone" />
<input value={form.email} onChange={set('email')} type="email" placeholder="Email" />
<textarea value={form.message} onChange={set('message')} placeholder="What do you need?" />
<button disabled={status === 'sending'}>Send request</button>
{status === 'error' && <p role="alert">Please leave a phone or an email, then try again.</p>}
</form>
);
}<script setup lang="ts">
import { reactive, ref } from 'vue';
const empty = () => ({ name: '', phone: '', email: '', message: '' });
const form = reactive(empty());
const status = ref<'idle' | 'sending' | 'done' | 'error'>('idle');
async function submit() {
if (!form.phone.trim() && !form.email.trim()) {
status.value = 'error';
return;
}
status.value = 'sending';
// crmLead never throws; it is undefined only if widget.js is not on the page
const res = await window.crmLead?.('quote_request', { ...form }, { value: 149900, currency: 'EUR' });
if (res?.ok) {
Object.assign(form, empty());
status.value = 'done';
} else {
status.value = 'error';
}
}
</script>
<template>
<p v-if="status === 'done'">Thank you! We will get back to you within one business day.</p>
<!-- No data-crm-lead here: this form sends through crmLead itself -->
<form v-else @submit.prevent="submit">
<input v-model="form.name" placeholder="Your name" required />
<input v-model="form.phone" type="tel" placeholder="Phone" />
<input v-model="form.email" type="email" placeholder="Email" />
<textarea v-model="form.message" placeholder="What do you need?" />
<button :disabled="status === 'sending'">Send request</button>
<p v-if="status === 'error'" role="alert">Please leave a phone or an email, then try again.</p>
</form>
</template>What happens when the form is submitted
- The widget always cancels the native submit — the page doesn't reload, and the form's
actionis not used. - It fires
crm:lead-beforeon the form. If any listener callsevent.preventDefault(), the story ends here and nothing is sent. - 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", ". - It sends the lead, protected by a one-time form pass (more on that in spam protection).
- It fires
crm:leadwith{ ok: true }or{ ok: false }inevent.detail. - If
okis true, it callsform.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
| Attribute | Example | What 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-ignore | no value needed | Goes 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.
| Event | When | event.detail | Cancelable |
|---|---|---|---|
crm:lead-before | Right after submit, before anything is sent | { name } — the event name | Yes: preventDefault() stops sending |
crm:lead | After the server answered | { ok } — true if the lead was accepted | No |
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):
| Slot | Field names that fill it | Max length |
|---|---|---|
| Name | name, fio, username, user_name, fullname, full_name, firstname, first_name, contact_name, client_name, имя, фио, ім'я | 200 |
| Phone | phone, tel, telephone, mobile, phone_number, contact_phone, телефон, тел | 40 |
email, e_mail, e-mail, mail, contact_email, почта, пошта, емейл | 320 | |
| Message | message, comment, comments, text, question, note, description, task, сообщение, комментарий, вопрос, повідомлення, коментар | 4000 |
- First match wins. If a form has both
phoneandmobile, 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 hascard,cc,csc,cvv,cvc,iban,ssn,secretorpassas a separate word — or ifpassword,passwd,pwd,token,csrf,xsrf,creditcard,cardnumberorccnumappears anywhere in the name.
| Field name | Sent? | Why |
|---|---|---|
card_number | no | card is a separate word |
cvv | no | a sensitive word on its own |
csrf_token | no | contains csrf and token |
user_password | no | contains password |
discard_reason | yes | card 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 /leadwith a secret key alike — so a sensitive field never ends up in the CRM, whichever way it was sent.
<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^[A-Za-z][A-Za-z0-9_]*$). A name that doesn't fit gets a warning in the console and { ok: false }.fieldsobjectrequiredoptions.valueintegeroptional149900 = 1,499.00. Stored only for events with “This event brings revenue”.options.currencystringoptionalEUR.Returns a Promise that resolves to:
okbooleantrue — 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>';
});formHTMLFormElementrequirednamestringrequiredcrmLead.TypeScript declarations
The widget is a plain script, so TypeScript doesn't know about it. Drop this into any .d.ts file:
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:
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/jsonSecret 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"
}'// Node 18+ — fetch is built in
const res = await fetch('https://back.sitecog.com/marketing/lead', {
method: 'POST',
headers: {
'x-event-key': process.env.CRM_EVENT_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'phone_order',
event_id: `order-${order.id}`, // a retry won't create a second lead
visitor_id: order.crmVid, // saved from the browser at checkout, if you have it
session_id: order.crmSid,
fields: {
name: order.customerName,
phone: order.phone,
message: order.comment,
delivery: order.deliveryMethod, // goes to "extra"
},
value: order.totalCents, // 149900 = 1,499.00
currency: 'EUR',
}),
});
const data = await res.json();
if (!res.ok) throw new Error(`CRM lead: ${res.status} ${data.message}`);
// data → { ok: true, duplicate: false }import os
import requests
res = requests.post(
"https://back.sitecog.com/marketing/lead",
headers={"x-event-key": os.environ["CRM_EVENT_KEY"]},
json={
"name": "phone_order",
"event_id": "order-1024",
"fields": {
"name": "Anna Schmidt",
"phone": "+49 30 1234567",
"message": "Two pairs of Air 3, graphite.",
},
"value": 149900,
"currency": "EUR",
},
timeout=10,
)
res.raise_for_status()
print(res.json()) # {'ok': True, 'duplicate': False}Request body
x-event-keyheaderstringrequiredsk_… from the CRM. Missing or revoked → 401 invalid_key.namestringrequiredn.fieldsobjectrequiredevent_idstringoptionalid.visitor_idstringoptionalsession_idstringoptionalcrm_sid). With it, the lead inherits the ad channel and UTM tags of that session. Alias: sid.valueintegeroptional149900 = 1,499.00. Stored only for events with “This event brings revenue”. Alias: val.currencystringoptionalEUR. Alias: cur.urlstringoptionalu.Response
{ "ok": true, "duplicate": false }okbooleanduplicatebooleantrue 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.
{ "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:
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.sidWith 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:
| Status | Body | What 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
| What | Limit |
|---|---|
| Requests per IP (all marketing routes together) | 120 per minute |
| Requests per site | 6000 per minute |
| Form passes per IP | 20 per minute |
| Request body | 8 KB |
| Name / phone / email / message | 200 / 40 / 320 / 4000 characters |
| Extra fields | up to 30; key up to 60, value up to 1000 characters |
| Phone | 7–20 digits to count as a contact |
event_id | up to 128 characters |
| Secret keys | up 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-leadmeanslead_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-beforelistener calledpreventDefault()— maybe not when you expected. - The widget isn't on this page. Without
widget.jsthe 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.comandconnect-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 onlocalhostor a staging domain that isn't added to the CRM givesunknown_domainin 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_passhaspassas a separate word and is never sent. Rename it to something likepromo_codeand it comes through. - It sits under
data-crm-ignore. The attribute may be on a wrapper a few levels up — afieldsetor adivyou 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.