Last week I logged into Brevo — the email service Newton uses — and saw something that made me wince. One of my paying customers was still sitting in the "Trial Reminder" list. He'd been charged a week ago. And he was still getting drip emails like "Your trial ends in 2 days, hurry!"

It wasn't a financial issue. The customer hadn't complained. But putting myself in his shoes — if I'd just paid for something and was still being marketed at like a free trial user, I'd assume the whole product was held together with duct tape.

So I opened a chat with Tim, my AI agent, and said: "We have a logic problem. Let's figure out how this even happened."

Root Cause: One Missed Webhook = Permanent List Mismatch

Tim walked me through the original design. It was event-driven — every time something happened in Stripe, the system fired an action at Brevo immediately:

  • Customer starts trial → call brevo_add_trial_contact() → put them in the Trial list.
  • Customer's first invoice paid → call brevo_move_to_paid() → move from Trial to Paid.
  • Customer cancels → call brevo_move_to_newsletter() → move to Newsletter.

Reads cleanly. The problem? It assumes every Stripe webhook arrives, always.

In real life, webhooks drop. The server might restart at exactly the wrong second. The network might glitch. The endpoint might respond too slowly and Stripe gives up after retries. It's rare. But it happens.

And when it happens to one specific event — say, an invoice.paid for one specific customer — the system never learns that the payment went through. So it never moves him out of the Trial list. He stays there forever, until a human (me) happens to notice and drag him out manually.

Tim summed it up in one line: "Event-driven design only remembers what it witnessed. If it didn't witness the event, the event might as well not have happened."

Tim Proposed Rewriting It as State-Driven

The shift Tim explained to me is easy to grasp: instead of asking "what just happened?", the system asks "based on what's currently in the database, which list should this customer be on?"

One single rule, derived from the subscriptions table:

  • is_trial = 0 + status is active or cancelling → PAID list.
  • is_trial = 1 + status is trialing or past_due → TRIAL list.
  • Anything else (no subscription, fully cancelled, abandoned trial signup) → NEWSLETTER list.

Tim wrote a single new function called brevo_sync_customer_list(customer_id). Pass it a customer ID. It reads the database, computes which list they should be on, calls Brevo's API to add them to the correct list and remove them from the other two. Done.

The old functions — brevo_add_trial_contact, brevo_move_to_paid, brevo_move_to_newsletter — didn't get deleted. Tim turned them into thin shims. Each one ends up calling brevo_sync_customer_list. So no matter which old code path fires, the actual list assignment is always derived from real DB state. There's no way for them to disagree anymore.

A Nightly Cron That Self-Heals

Tim didn't stop there. It pointed out the obvious gap: "If any customers are already in the wrong list right now, the new code only fixes them when something new happens to their account. Customers who are quietly stuck won't get fixed automatically."

So Tim wrote a backup script called brevo_reconcile.py:

  1. Read every customer in the database.
  2. Loop through them. Call brevo_sync_customer_list on each one.
  3. If any sync fails, send me a Telegram alert.

Then it set the script to run as a cron job every night — once at 4:17 AM on the Thai side, once at 4:23 AM on the global side, while the system is otherwise idle.

The result: if a webhook ever drops again and a customer ends up on the wrong list, by the next morning at 5 AM the cron will have re-synced everyone. The DB and Brevo will match. The customer might be on the wrong list for a few hours. They will never be on the wrong list for weeks.

Three Latent Bugs Disappeared in the Same Rewrite

Here's the part I didn't expect. Once we switched to state-driven, Tim quietly told me three other bugs I didn't even know about had also been fixed by the same change:

Bug 1: The "abandoned trial" email function (people who entered their email but never added a card) used to add those people to the Trial list. They had no real subscription, but they were still getting "trial ends in 2 days" emails. The new system sees no subscription → routes them to Newsletter. Correct.

Bug 2: The delete_server function had a hidden DELETE FROM customers that was failing because of a foreign key (the subscriptions table still referenced the row). The exception killed the rest of the function — including the brevo_move_to_newsletter call right below it. So cancelled customers were quietly staying in the Paid list. Tim removed the DELETE entirely (we keep the customer row in case they come back), and the reconcile cron mops up.

Bug 3: The original case I noticed — a silently-dropped invoice.paid webhook. This one fixes itself the next time the cron runs. No code change needed. The architecture handles it.

What I Took Away From This

I'm not a career developer. I learn this stuff by working alongside Tim. From this episode I jotted down two principles:

1. Webhooks "usually arrive" — they don't "always arrive." If you build a system whose state depends on receiving every single webhook from a third party, you are building something that will eventually have weird, untraceable bugs. (A few days later I hit exactly this — an entire Stripe event type wasn't even subscribed, and two paying customers stayed flagged as trial until I noticed.)

2. State-driven design + a reconcile cron = self-healing. Even if I write buggy code in the future, or someone (me) edits the DB directly via SQL, the system will return to a consistent state on its own within 24 hours. For a small SaaS with no ops team, this is a pattern I'll be reusing everywhere.

This is the same principle behind the 24-hour grace period for failed card payments I wrote about earlier. Don't trust a single event. Treat the database as the source of truth and have a process that quietly re-checks.

I Could Not Have Done This Alone

I sat back and thought about how I'd have handled this without Tim:

Option 1 — Open Brevo and manually drag customers to the right list. Fixes the symptom for one customer. The bug stays.

Option 2 — Hire a freelancer to write the sync. Wait a week or two. Pay several hundred dollars. Hope they understand my schema.

Option 3 — Learn the design pattern myself, read the Brevo API docs, write the Python, deploy it on both servers, write a cron, test. Probably a month.

With Tim it was an afternoon. Rewrite the old logic. Write the reconcile script. Set up the cron on both Thai and English servers. Test. Commit. Push to production on both sides. Send me a Telegram summary. I just watched.

The thing that made it possible is that Tim already knew my business. It already knew Newton runs as two parallel deployments (Thai + English). It already knew the DB schema. It already knew which Brevo lists exist (TH = 5, 7 / EN = 6, 8 / shared = 3). It already knew which cron times don't collide with anything. Because Tim lives on my server, none of this had to be re-explained. That's the difference between using AI and having an AI that works for you.

If You Run a Business and Want an AI Like This

If you run anything online — Stripe, Brevo, ConvertKit, any combination of services — sooner or later you'll hit a webhook drop, a list mismatch, or a state divergence somewhere. And you'll have to track it down yourself, by hand, on a Tuesday night.

That's exactly why I built Newton. A private server with an AI agent that knows your business, has access to your database, can SSH into your server, can deploy code, and can build custom tools for problems no SaaS would ever bother to solve. Ready to use in 10 minutes. Fully managed by my team. Starts at $29 a month.

So next time you find a paying customer still getting "trial ends soon" emails at 2 in the morning, you have an AI of your own that can rewrite the whole sync layer before breakfast. Try Newton at newton.incomeinclick.com.

— Pond