Should you build your own CRM at all?
Most guides to building a CRM are guides to configuring one: pick a no-code builder, drag the fields in, connect a form. For a lot of businesses that is the right answer, and it should be tried first. A hosted CRM is cheaper than a build for as long as the way you work fits inside it.
Ours started from a different problem. A lead existed only as a message in the managers’ chat, and there was no answer to the simplest question an owner asks — how much did we make this month. A boxed CRM would have answered that. What it would not have done is let the website write into it directly, let AI agents read it without a vendor’s API tier, and let us change any part of it the same afternoon. Those three things are why the businesses run their own code, and they are the reasons worth checking against your own before you build.
The CRM data model: three tables and a history
Strip a small-business CRM down and it is three tables — users, leads and deals — plus an events table that records every change. Everything else is a view over those four.
- Leads carry who asked, how to reach them, which page they came from, a status, the manager assigned, and a reason when a lead is lost. They also carry the fields that make each business different: a device and its condition at the buyback, a device and the work needed at the repair service, a car and the service wanted at the workshop.
- Deals are what a lead becomes when it is won: a purchase in stock at the buyback, a device in repair, an order in work at the workshop. Money is stored as integers in minor units, never as floats, and profit is computed in SQL on closed items only.
- Events record every change with who made it, what it was and, since we added the columns, the status before and after. This is the table that answers “who changed this, and when” months later, and it is also what an analytics board reads.
Each business speaks its own language through its statuses, and the shape underneath stays the same. A lead goes from new to in progress to one of three outcomes: won (bought, accepted for repair or order, depending on the business), no answer or refused. Winning a lead creates the deal and moves the lead’s status in one transaction, so the two can never disagree.
One rule belongs in the schema rather than in the code: a closed deal must have its price and date, and an open one must not. A CHECK constraint enforces it, so no screen, import or future endpoint can write a sale without a sale price. Simplified, the shape is:
CREATE TABLE deals (
id INTEGER PRIMARY KEY,
lead_id INTEGER REFERENCES leads(id) ON DELETE SET NULL,
status TEXT NOT NULL CHECK (status IN ('open', 'closed', 'cancelled')),
cost INTEGER NOT NULL, -- minor units
price INTEGER, -- set when closed
closed_at TEXT,
CHECK ((status = 'closed') = (price IS NOT NULL AND closed_at IS NOT NULL))
);
Two smaller decisions are worth copying. Migrations are an ordered list of SQL statements numbered in the database’s own version counter, and a migration that has shipped is never edited — a new one is added instead. And the dropdown vocabularies (device categories, services) are imported from the website’s own code rather than copied, so the form and the CRM cannot drift apart.
Write the lead before you announce it
The order of two calls decides whether leads get lost. The website’s form endpoint writes the lead into the CRM first, and only then tells the managers’ chat. The reason is that the chat message can then say whether the CRM write worked.
const ingestKey = randomUUID(); // one key per request
const crm = await postToCrm(lead, { ingestKey, timeoutMs: 3000, retries: 1 });
const text = format(lead) + (crm.ok ? '' : '\n⚠️ Not saved in the CRM — add it by hand');
await notifyChat(text, crm.ok ? statusButtons(crm.id) : null);
Every hop then has a decided failure, because “it usually works” is how leads disappear:
- CRM unreachable. The visitor still sees success, and the chat message carries a warning line so a manager enters the lead by hand. Nothing is lost; it is just not automatic.
- Chat unreachable. At the repair service the endpoint sends to more than one chat and retries once; if nothing is delivered it appends the lead to a file on the server and answers the visitor normally. The other two sites answer with an error, so the visitor knows the request did not go through.
- CRM not configured at all. This one deserves its own line. Each site’s deploy rewrites its environment file, and once a deploy that forgot the CRM settings silently disconnected the CRM. A disconnected CRM looks exactly like one that was never deployed — which is why the workshop’s site now has a health endpoint that reports whether the CRM and the chat are configured.
Idempotency, spam and the duplicate you cannot prevent
The CRM rejects a second lead with an ingest_key it has already seen and answers “duplicate”, so the site’s own retry cannot create two leads. The key is compared, and the CRM’s intake token is checked in constant time against a hash, with the endpoint closed outright if the token is missing or short.
The honest limit: the key is generated per request, so a visitor who submits twice, or resubmits after an error, creates two leads. We cap that with a rate limit rather than pretend it cannot happen. Spam is handled in three cheap layers that between them stop almost all of it — a hidden field that only bots fill, a minimum time between rendering the form and submitting it, and a limit of a few leads per address per ten minutes. On two of the sites, visitors who send Do Not Track get their source, campaign and click identifiers blanked before the lead is stored.
Spam rules can lock out the visitors you want. When AI assistants began filing leads, the timing check silently dropped them as bots — an assistant submits instantly — and one assistant filled the hidden field first. A lead that names its agent now skips the timing check, and the hidden field is never part of what an agent is shown.
Telegram as the manager’s interface
At the buyback every lead arrives in the managers’ Telegram chat with four buttons: in progress, bought, refused, ignore. The design decision that made this simple is that the button’s callback data carries the CRM’s own status key — lead:<id>:<status> — so a press is a status change, with no mapping table between the chat and the CRM. That is all a Telegram CRM needs to be: the CRM keeps the record, and the chat is the fastest screen onto it.
- The webhook trusts nothing it cannot verify. It checks a secret header derived from the bot’s token, ignores presses from any other chat, and logs the presser’s name as the actor.
- It always answers 200 once the secret matches. Otherwise the messenger retries the update, and a retried “bought” would record a purchase twice.
- “Bought” asks for the price. The bot replies to the presser with a forced-reply prompt — how much was paid? — reads the amount from the answer, re-asks if it is unclear, and records the deal. The buttons are then replaced by a link to the deal in the CRM.
- A purchase cannot be undone from the chat. After the deal exists, every button says to change it in the CRM. Undo belongs where the history is.
One lesson came from the first test with two people. After a press, the buttons looked as if they had changed only for the person who pressed them, and two kinds of tick side by side confused everyone. The fix was to write the state into the message itself — a last line saying the status, who set it and when — and redraw the buttons in the same edit. The repair service and the workshop have no buttons: their managers change statuses in the CRM’s own pages.
Opening the CRM to AI agents
The reason to own the code is that every part of it can be opened to whatever agent comes next. Two doors exist today, one in each direction: in, a lead format an AI assistant can call; out, a read-only feed. Both are plain CRM API integration, and neither needs anything from a vendor.
In: leads from AI assistants. The repair site publishes its lead format as an OpenAPI 3.1 document generated from the same schema that validates the form, plus an API catalog at /.well-known/api-catalog. The published schema is a subset of the real one, so the hidden anti-spam field and the timing field are never shown to an agent, and the field descriptions are written for the assistant reading them. The workshop registers a WebMCP tool, submit-lead, on every page — programmatically rather than through form attributes, because the declarative version would have exposed the hidden field. Leads an assistant files are flagged in the managers’ chat, with a reminder to check the phone number first. One small trap: robots.txt had blocked /api/, so the lead endpoint is now explicitly allowed above that rule.
Out: a read-only feed. Each CRM exposes one read-only endpoint that an analytics board polls: events since a cursor, leads and deals without names, phones or comments, behind its own token, unreachable from outside the server and reached through a tunnel whose user can do nothing but forward that port. If the cursor ever goes backwards, the board knows the database was restored and re-reads it. That board is what AI agents query — how the read-only access is built.
A self-hosted CRM you can leave alone
A CRM nobody has time to administer has to administer itself. The parts that make that true:
| Concern | What it does |
|---|---|
| Separation | The CRM is its own application with its own container and domain, so a bug in the panel cannot take down the public site. |
| Database | One SQLite file in write-ahead-log mode, foreign keys on, a busy timeout; only the CRM writes it. |
| Sign-in | No auth library: scrypt password hashes, a dummy hash for unknown logins so timing reveals nothing, sessions stored as hashes, two roles (only the owner sees profit and exports), and a login limit that groups IPv6 addresses by /64. |
| Deploys | A manual pipeline that typechecks, smoke-tests the built image, ships it, waits for it to report healthy and rolls back to the previous image if it does not. |
| Backups | Every night a consistent copy and an integrity check; an encrypted copy goes to a machine on the local network, kept daily for a month and monthly for a year. |
| Exposure | Bound to localhost behind the web server; the intake and feed endpoints answer 404 from the outside world. |
One mistake worth repeating so nobody else makes it: an early smoke test ran against a live environment and sent real “test” leads into the managers’ chat. Checks that create leads now run only when explicitly allowed.
Copying one CRM into three businesses
The CRM was built for the buyback first and then copied, file for file, into the repair service and the workshop. The copies show where the variation in a small CRM actually lives:
- Identical in all three: sign-in, sessions, user management, the login form.
- Lightly changed: rate limits, money formatting, navigation, the read-only feed.
- Heavily changed: the database schema and migrations, the intake endpoint, the statistics — because that is where each business’s vocabulary lives.
- Only in one: the Telegram buttons and price prompt at the buyback; repairs instead of deals, and the agent flag on the lead card, at the repair service; the review request with QR codes, shown to every customer the same way, at the workshop.
That split is the argument for building small and copying rather than building one configurable product: the shared part is the part nobody should have to think about twice, and the part that differs is short enough to rewrite per business in days.
What a custom CRM like this costs
A small custom CRM of this shape — leads, deals, history, roles, a website intake and a chat integration — runs $7,000–$12,000 and takes 2–3 weeks. What moves it inside the band is the number of integrations and how much reporting the owner needs, not the number of screens. Adding AI to it later — drafts, summaries, an agent that reads the data — is a separate workflow in our AI automation band. Custom CRM development · every band we publish · the case study.