Software engineering

How to build a CRM on your own code: lessons from three

One small CRM, built once and copied into three local businesses of our own. The data model, the lead that is never lost, buttons in a chat, and a door for AI agents — with the mistakes that shaped each.

Short answer. Build your own CRM only when a hosted one cannot be made to fit, or when you want your website and AI agents to work with it directly. If you do, the CRM is small: three tables and a history, written by one application only. The hard part is not the screens but the edges — a lead written before anyone is told about it, a key that makes a retry harmless, a decision for what happens when each hop is down, a chat that changes a status instead of replacing the CRM, and read-only doors for the systems that come later. Our three businesses — an electronics buyback, a device repair service and a car tuning workshop — run one CRM built that way, copied twice.

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.

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))
);
A simplified illustration, not a copy of any of the three schemas — each business names its statuses and columns its own way. The point is the last line: the database refuses a closed deal without a price and a date, the same principle as preventing double bookings with a constraint rather than a validator.

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);
Simplified. The CRM call has a 3-second timeout and one retry after a second, sending the same key both times, so the worst case keeps a visitor waiting about seven seconds rather than indefinitely.

Every hop then has a decided failure, because “it usually works” is how leads disappear:

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.

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:

ConcernWhat it does
SeparationThe CRM is its own application with its own container and domain, so a bug in the panel cannot take down the public site.
DatabaseOne SQLite file in write-ahead-log mode, foreign keys on, a busy timeout; only the CRM writes it.
Sign-inNo 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.
DeploysA 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.
BackupsEvery 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.
ExposureBound 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:

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.

Common questions

About building a CRM of your own.

Usually not at first. A hosted CRM or a no-code builder is cheaper for as long as the way you work fits inside it. Building your own pays when the workflow does not fit a product without workarounds people maintain by hand, when the data has to stay on your own systems, or when you want AI agents and your website to read and write the CRM directly. Our three businesses run their own CRM for the last two reasons.
For one business with a handful of managers, SQLite is enough and removes a server to run. Ours uses one SQLite file in write-ahead-log mode, with foreign keys on and a busy timeout, and only the CRM writes to it — the website posts leads over HTTP instead of opening the file, which avoids lock errors. PostgreSQL becomes the better choice when several applications write, when you need many concurrent writers, or when the CRM becomes multi-tenant.
Write the lead to the CRM first and announce it second, so the announcement can say whether the write worked. Give each request an idempotency key so a retry cannot create a duplicate, use a short timeout with one retry, and decide in advance what happens when each hop is down: our chat message carries a warning line when the CRM write failed, and one of the sites writes the lead to a file on the server when the chat cannot be reached.
Yes, if the chat is treated as an interface to the CRM rather than as the CRM. In one of our businesses every lead arrives in the managers' chat with buttons whose callback data carries the CRM's own status keys, so a press is a status change with nothing to translate. The webhook checks a secret header and always answers 200 once it matches, so the messenger never retries a press and records a purchase twice.
A small custom CRM of the shape described here — leads, deals, history, roles, a website intake and a chat integration — runs $7,000–$12,000 and takes 2–3 weeks at Yarvixo. What moves it inside the band is the number of integrations and how much reporting the owner needs, not the number of screens.

Need a CRM that fits the way you work?

Tell us how leads reach you today and who answers them. We will tell you whether a hosted CRM will do — and if it will not, put a fixed scope and price in writing within 48 hours of the call.

Message received

We’ll review your enquiry and respond within one business day.

Related reading