The old follow-ups were fixed sequences. When a lead's status changed, the app scheduled every email right away: day 1, day 3, day 7. It couldn't wait to see whether the person booked, send something different if they didn't, or hold a text until morning.
The app is the CRM a group of exercise clinics uses to keep track of everyone who has shown interest. Someone who fills in a form on the website becomes a lead, and the CRM sends the emails and texts until they sign up.
Now staff draw a journey, a bit like a flowchart. Send this email. Wait two days. Did they book? If yes, stop. If not, send a text, but only during the clinic's hours, in that clinic's time zone. People join a journey automatically when they match a group staff have defined, like "missed their first appointment".
Each message once
The hard part is making sure each person gets each message once. Many people can be partway through a journey at once, and several workers, copies of the program running side by side, process them at the same time. Five rules keep that safe:
- A person can only be in the same journey once at a time. The database itself refuses a second copy, so it doesn't matter if two parts of the app try to add them at the same moment.
- Only one worker can move a given person forward, one step at a time. For the engineers: a Postgres row lock with
SKIP LOCKED. - A wait is just a wake-up time saved on the person's record. Every minute, a job picks up anyone who's due. If staff change a wait, nothing has to be rescheduled, and a restart loses nothing.
- Before a message goes out, the app writes down that it's about to send it. If a server crashes partway, the retry finds that note first. I picked that order on purpose: if this ever has to fail, it should fail towards trying again, never towards quietly skipping someone.
- Unsubscribes are checked right before each send, so they take effect on the very next message.
One plan didn't survive. I meant to reuse the old system's sending code so journeys would get its safety checks for free. Then I found the old tables couldn't hold a message that wasn't part of an old-style sequence. Journeys got their own record of sends instead, and I copied over the few checks they needed. Big decisions get a short written record of what we decided and why, including the ones I later reversed. This one still shows the first plan next to the correction.
To switch over, staff can turn an old sequence into a draft journey in one click, then retire the old one. Email templates are checked before a journey can go live, so a typo in a placeholder like {{ frist_name }} gets caught before it reaches anyone.
Bring your own AI
Staff can use an AI assistant to build journeys, groups and email templates, without the CRM calling an AI at all. The CRM writes a set of instructions from its own current setup: the kinds of steps, the statuses, the existing templates. Staff paste that into whatever assistant they already use and paste the reply back. The CRM checks the reply exactly the way it checks a normal save. Since the instructions come from the code, they stay accurate when the code changes.
Where it stands
The first journeys went live at the end of July. Since then, 322 people have gone through them and they've sent about 1,500 emails and texts.
Rails 8.1, Postgres, Sidekiq, React Flow, Twilio, SendGrid.