Build Crisp
KINDAreplaces $45/mosaves $540/yrback to the verdict
An embeddable chat widget for your site, a shared inbox where a small team answers from one place, message delivery that survives a dropped connection without losing or duplicating anything, email fallback when nobody is online, and searchable history with a retention policy.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the server and the tooling.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - installfree
Why Holds conversations and provides the full text search in Phase 5.
Get it Install locally with your package manager or run it in Docker, and create a database for the project. open ↗
Verify
psql connects and SELECT version() reports 16 or higher - API keyfree tiers typically cover a few thousand messages a month; inbound parsing is sometimes on a paid tier, so check before choosing
Why Phase 4 needs both directions. Plenty of providers send well and do not parse inbound mail, which would leave the fallback half built.
Get it Choose a provider that explicitly supports inbound parsing or inbound routing, verify a sending domain with their DNS records, and point an MX record at their inbound host. Confirm inbound support before you commit, because it is the part that varies.
Verify
A test message to your inbound address arrives at your webhook - have readyroughly 10 to 15 USD a year
Why Needed for the sending domain, the inbound MX record and the certificate.
Get it Any registrar, then add the DNS records your email provider specifies. Propagation can take a few hours, so do this first.
Verify
dig MX for your inbound subdomain returns the provider's host - have readyfree
Why Same-origin testing hides exactly the CORS and origin problems Phase 1 exists to catch.
Get it Any static host, or a second local server on a different port. A different port is a different origin, which is enough.
Verify
The widget loads and connects from that page
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- sites(id, name, origin_allowlist, widget_key) - contacts(id, site_id, external_id, email, name, first_seen_at, last_seen_at) - conversations(id, site_id, contact_id, state, assignee_id, subject, created_at, last_message_at) - messages(id, conversation_id, seq, author_kind, author_id, body, delivered_at, read_at) · seq is a per-conversation monotonic integer - agents(id, email, name, role) - canned_responses(id, site_id, shortcut, body) - The seq column is the backbone: it is what a reconnecting client asks for and what makes delivery verifiable.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://user:pass@localhost:5432/inbox | Your PostgreSQL connection string. |
PORT | required | 3000 | Where the server listens. Put a reverse proxy in front for TLS. |
PUBLIC_URL | required | https://inbox.example.com | Used to build widget URLs and reply addresses. Wrong here means email replies go nowhere. |
EMAIL_API_KEYsecret | required | re_... | Your transactional provider's dashboard. |
INBOUND_DOMAIN | required | reply.example.com | The subdomain whose MX record points at your provider's inbound host. |
REPLY_TOKEN_SECRETsecret | required | generate-a-long-random-value | Signs the per-conversation token in reply addresses. Rotating it invalidates outstanding reply addresses. |
RETENTION_DAYS | required | 365 | Conversations older than this are deleted. Say this number in your privacy policy. |
The build, in order
Widget, origin control and one conversation
A script on another origin can start a conversation, and an origin you did not authorise cannot.
No framework, no build step for the widget itself. It creates a shadow root so the host page's styles cannot break it and yours cannot leak out.
Files
public/widget.jsCheck the Origin header against the site's allowlist during the WebSocket handshake and refuse there, before any message is processed.
Files
src/ws.tsThe visitor session identifies one conversation. Write a test that asks for another conversation's messages with a valid widget key and expects a refusal.
Files
src/auth.tstest/scope.test.ts
done when · tick each as it passeswatch out- The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations.
Delivery that survives a dropped connection
Messages sent while a client was offline arrive exactly once when it comes back.
A per-conversation counter updated in the same transaction as the insert, with a unique constraint on conversation and sequence.
Files
src/messages.tsmigrations/An endpoint that returns messages after a sequence number, capped and paginated. The client calls it on reconnect before resuming live delivery.
Files
src/backfill.tsBuffer live messages during backfill, then deduplicate by sequence when resuming. This window is where duplicates come from and it needs a test.
Files
test/reconnect.test.ts
done when · tick each as it passeswatch out- Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly.
The shared inbox
Two agents can work the same queue without both answering the same person.
Sorted by last message, filtered by state and assignee. Keyboard shortcuts for assign, close and next, because this is a screen people live in.
Files
src/inbox/Ephemeral, in memory, never persisted. Presence that outlives a disconnect is worse than no presence.
Files
src/presence.tsA message from the visitor on a closed conversation sets it back to open and puts it in the queue. Test it, because it is easy to miss and it strands people.
Files
test/state.test.ts
done when · tick each as it passeswatch out- Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action.
Email fallback
A visitor who leaves gets the answer anyway, and their reply lands back in the same conversation.
Ask only when it is useful, and say why. An email field on every conversation is friction for the ones answered in thirty seconds.
Files
public/widget.jsA per-conversation token in the local part of the reply address. Sign it so a guessed token cannot inject into a conversation.
Files
src/email/send.tsRoute the provider's inbound webhook to the conversation named by the token, and trim quoted text and signatures so the thread stays readable.
Files
src/email/inbound.ts
done when · tick each as it passeswatch out- Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous.
Search, canned replies and retention
Finding an old conversation is fast, and old data does not accumulate forever by default.
A generated tsvector column with a GIN index. Seed tens of thousands of messages before deciding it is fast.
Files
migrations/Shortcut expansion in the agent composer. Small feature, large daily saving.
Files
src/canned.tsOne function that removes rows, attachments and index entries. Test by searching for the deleted phrase and expecting nothing.
Files
src/delete.tstest/delete.test.ts
done when · tick each as it passeswatch out- Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards.
Running it for realproduct builder
It stays up, it resists abuse, and you can restore it.
Per visitor and per site, with a low ceiling on new conversation creation specifically, because that is the expensive one.
Files
src/ratelimit.tsOpen socket count, message delivery lag, database connection saturation and disk. Alert on lag rather than only on the process being alive.
Nightly dump plus attachment storage, off the machine, then restore onto a clean server and record the date.
Files
README.mdterminalpg_dump "$DATABASE_URL" | gzip > inbox-$(date +%F).sql.gz
done when · tick each as it passeswatch out- A widget embedded on a public page is an open endpoint. Without a per-visitor and per-site rate limit, one script can create a hundred thousand conversations overnight and your inbox is unusable in the morning.
That is the whole plan for Crisp. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- The multi-channel inbox covering social, SMS and marketplace messages.
- The knowledge base, campaigns and the rest of the bundled suite.
- Deliverability engineering as volume grows.
- AI resolution trained on a large corpus of real support conversations.
- Mobile applications for agents.
- multi-channel inbox, chat delivery, knowledge base, campaigns, and workspace pricing
- omnichannel integrations
- large-scale deliverability
- AI resolution tuning
- enterprise routing and compliance
- Business hours with an away message, which is small and changes the experience a lot
- Conversation tagging and a weekly volume report
- A public status page fed by the same monitoring
Need the files? The project pack on the verdict page hands your agent the whole brief · more customer support.