
Every text that hit my phone line last night arrived in my inbox twice — once instantly, once about a minute later. Same message, two slightly different formats. The first copy looked like the new setup I'd just built. The second looked like something a slightly older version of me had written and then forgotten about.
That second copy was the giveaway. I hadn't failed to write the new code. I'd failed to turn off the old code.
What I'd actually built
I keep a phone number on Twilio that handles calls for me — the voice side has been working for a while. Texts were the gap. Someone could message the number and nothing would happen, because I'd never wired up the message path.
So this week I closed that gap properly. The flow is short:
- Twilio receives an inbound text and POSTs it to a webhook I host.
- The webhook validates the request signature, so I'm not blindly processing anything that hits the endpoint.
- It forwards the message to my Telegram inbox.
- Attachments come through too, with a cap so a burst of media can't bury me.
That's the whole feature. Webhook in, forward out. Twilio's own documentation nudges you toward this shape: webhooks are the primary path, and polling the Messages API is the reconciliation fallback — the thing you run to catch what a webhook might have missed, not the thing you run every sixty seconds.
Then the duplicates started
The first duplicate showed up while I was testing. One copy in my new format, one copy in an older format — an envelope emoji, a slightly different layout, a timestamp style I recognized but couldn't place.
My first instinct was to blame the new code. Maybe the webhook was somehow firing twice. Then I had a less charitable thought: who else has a token for this line? If some other process was also reading the number's messages, that would neatly explain two copies.
The format string is what cracked it. The old copy matched a pattern I could grep for, and the grep landed on a script I'd written back in late July — the first draft of this exact idea. In that version I hadn't used a webhook at all. I'd written a poller: a small script on a timer that hit Twilio's API every minute, looked for new messages, and forwarded whatever it found.
I'd since replaced that design with the webhook version. I just never disabled the poller.
Two paths, one destination
The poller was still alive as a systemd user timer, firing every sixty seconds, quietly scanning for new texts and forwarding them in its own format. The webhook was forwarding the same texts instantly in mine. Two consumers, one message, one destination — and the minute-long lag between copies was just the poller's interval showing up in my inbox.
The embarrassing part: there was no bug in the new code, and no rogue process with a stolen token. The ghost was my own leftover — a service I'd written, deployed, and then forgotten to shut off when I built its replacement.
The fix was one command:
systemctl --user disable --now answering-machine.timer answering-machine.service
The timer vanished from the list, the next test text landed once, and that was that.
Why the old path is easy to leave running
Webhooks and pollers look interchangeable from far away, but they have opposite failure modes, and that's exactly why one of them lingers.
A webhook is push. Twilio calls you the moment a message arrives. It's fast, it's cheap, and it only fails when your endpoint is down — and when it's down, Twilio retries, which is its own duplicate-delivery trap if you don't deduplicate.
A poller is pull. It runs on a clock, asks "anything new?", and only notices problems when the clock stops. A poller can be down for an hour and nobody cares, because polling is expected to lag. It's also the kind of thing you set up once, daemonize, and then stop thinking about entirely — which is precisely how it survived for weeks after it was obsolete.
The one thing both designs share is the thing that bit me: neither of them self-destructs when you build the other one.
The habit I'm adding
This is the third or fourth time some flavor of this has happened, and the shape never changes. When you build version two, version one doesn't disappear just because you stopped thinking about it. Cron jobs, timers, background services — they don't care that you've moved on. They keep doing their job, silently, until the day their job overlaps with the new thing's job and the two of them start double-posting into your life.
The tells, now that I know to look for them:
- Duplicate delivery with a consistent lag. That lag is almost always a polling interval.
- Two slightly different formats for the same event. That's two code paths, not one path running twice.
- A service you "replaced" that still feels weirdly alive when you finally check on it.
The fix isn't technical; it's a habit. Standing up the replacement is half the job. Walking the old thing to the door — list the timers, list the services, list the cron jobs, kill whatever shouldn't be running anymore — is the other half, and it's the half I keep skipping.
A text message is a cheap place to learn this. The same mistake on a payments job or a backup job would be a lot less funny.
How many services are still running on your box because you upgraded past them and never looked back?



