How to build an IFTTT alternative
How to build an IFTTT alternative pairs one trigger with one email or webhook, and remembers each seen item so a restart sends that action once.
How to build an IFTTT alternative is one trigger, one action, and a memory of the items that already fired. IFTTT is that product. The catalog line is “If this happens, then do that. For your life, not your company.”
The card lists $2.99–$14.99 a month. The first estimate is 8 hours. The project name is Ifso. The verdict is “If simple, then build”.
The IFTTT post is the teardown. This guide is the 8-hour build: the poll, the screens, and the prompts in order.
A work sequence with a signed hook and one POST is the Zapier guide. Zapier is $30–$69 a month, and the first estimate is 3 days. Reading the feed yourself is the RSS reader guide. Feedly is $7–$12 a month, about 8 hours.
The sample applet is New post on flaviocopes.com. The feed is https://flaviocopes.com/rss.xml. The action emails [email protected]. On 7 November 2026 the first item was “Build a private AI feature with a local model, Ollama and Node.js”, and the guid was https://flaviocopes.com/local-ai-feature/. The first poll marks that guid seen and sends no mail.
How to build an IFTTT alternative from one pair
The IFTTT page splits the work into three rows.
The first row is 8 hours. Five personal triggers, email, and webhook actions. The trigger kinds are RSS, weather, and a calendar file. The actions are email and a webhook. Five is how many chores the estimate expects you to run. The kinds stay at those three.
The second row is 3 days, 24 hours. Polling connectors, run history you can filter, retries, and quiet hours. The third row is a month and up, 160 hours. Hundreds of services, an OAuth zoo, and mobile apps.
If you tell an agent to “clone IFTTT”, it aims at the third row. Paste the prompts below and keep Ifso on the first row.
What the 8-hour build does
You create an applet with a name, one trigger, and one action. It starts on. You can pause it. The configuration stays. A cron poll reads the source, writes a seen id and a run in one transaction, then sends. The first poll of a new applet marks every current id seen and sends nothing. A guid you have already stored stays quiet, even when the feed changes the date. A failed send keeps that one run, and a retry button uses the same row.
The weather fixture is Rain over 2 mm in Turin. Latitude 45.07, longitude 7.69, timezone Europe/Rome, threshold 2 millimeters. The calendar fixture is Bootcamp.dev office hours, from an ICS URL you already export. Both sit in the same list as the RSS applet.
What it leaves out
Quiet hours, automatic backoff, a dry run, and a filtered history are the 24-hour row. A recipe directory, OAuth for other people’s accounts, smart-home devices, teams, and a phone app are the 160-hour row. A device banner needs that app. The notice in this row is the email.
Where the poll runs
Node.js and Astro render the list, the editor, and history. Use TanStack Start if React is your weekday language. The stack post is which tab you copy. The card’s capability line is Astro, Workers, and D1. The rows live in D1. A scheduled worker polls. The page request never fetches a feed.
Save, pause, resume, and the retry button are request paths. Each one writes D1 and returns. If D1 is down, the form shows an error and the previous row stays. A save that times out talking to a feed is the wrong shape. You would be waiting on flaviocopes.com to store a recipe.
The worker runs on a schedule. The default gap is 15 minutes, stored in settings, and the editor refuses a number under 15. For each applet that is on, the worker takes a lease of two minutes. A second tick that finds a live lease skips that applet. A dead worker loses the lease, and the next tick can enter.
Then it fetches. RSS and calendar get 8 seconds and a 1 MB cap. Weather uses the same timeout. A timeout, a non-200, or a body over the cap writes one failed run with the reason “source unreachable”. The seen ids stay as they were. The next tick can try the same items. An empty or unreadable feed is the same failure. It is a bad day for the source, and the archive must stay put.
When the fetch works, the worker decides. The first poll, meaning last_poll is empty, inserts every current id as seen and one skipped run each, reason “first poll”, then sets last_poll. It sends nothing. A later poll inserts a seen id and a run in state sending in one transaction. The unique key is the trigger id plus the item id. After that commit, it sends. Success marks the run succeeded. A provider error marks it failed and stores the status code. The seen id stays, so the next tick cannot open a second run.
A crash before the commit leaves both rows absent, so the next tick can still fire that item once. A crash after the commit leaves a run in sending. The next tick leaves it there. History shows sending, and you press retry. Automatic recovery is the 24-hour row, and it has to carry the same dedup key.
Email and the webhook leave from the worker. The transactional provider is the one you already send mail with. The webhook is an HTTPS URL you typed. The worker signs the raw body with HMAC-SHA256 and puts the hex digest in X-Ifso-Signature. The secret lives encrypted on the action. History stores the title, the link, and the status code. The secret, the raw feed, and the signature stay out of that row.
A paused applet is skipped before the fetch. On resume, the next poll marks every id currently in the source as seen, with skipped runs, reason “paused gap”, and sends nothing for those ids. An id that appears after that poll can fire.
Every query is scoped to the owner. A guessed applet id from another account is a 404, the same response as a missing id.
What an applet stores
The card lists Applet, Trigger, Action, and Run. All four exist in these 8 hours. Seen ids are part of the trigger. They are the guardrail on the card: deduplicate so a restart cannot fire the same action twice, and store third-party credentials encrypted.
An applet is New post on flaviocopes.com. It belongs to one owner. It is on or paused. It has a name, one trigger, and one action. Deleting it deletes the trigger, the action, the seen ids, and the runs.
A trigger is one kind: rss, weather, or calendar. RSS stores the feed URL. Weather stores a label, latitude, longitude, a timezone, and a threshold in millimeters. Calendar stores an ICS URL. All three store last_poll, a lease timestamp, and the seen ids. A fourth kind is refused at save.
A seen item is the trigger id plus a stable item id. For RSS the id is the guid, and if the guid is missing it is the link. A republished post with the same guid is the same item when the date moves. For a calendar event the id is the VEVENT UID. For weather the id is the local date the forecast returned, 2026-11-08 in Europe/Rome. A day under the threshold is left out of the seen set, so a later forecast that same day can still cross 2 mm and fire once. Seen ids older than 90 days are deleted. A feed that republishes something from last spring can fire again after that. That window is the one this row defends.
An action is email or webhook. Email stores the address, defaulting to the address in settings. Webhook stores an HTTPS URL and an encrypted signing secret. An http:// URL is refused. The secret is shown once, when you create or rotate it.
A run is one decision about one item id. The dedup key is the applet id plus the item id, unique. Status is text, one of skipped, sending, succeeded, failed. Text, so the 24-hour row can add queued without a new table. A skipped run stores a reason, first poll or paused gap. A failed run stores temporary or permanent, the status code, and a short summary of the title and the link.
A settings row is the owner email, the owner timezone, and the poll gap in minutes. The fixture email is [email protected]. The fixture timezone is Europe/Rome, because the Turin day key has to match the forecast day.
The screens you ship
The list, the editor, history, and a short settings page. The four of them are the whole product in this row.
Applet list
Home. Each row shows the name, the kind, the action, on or paused, and the last run state. New applet sits in the header. The three fixtures fit without search. Pause is on the row. Delete asks you to confirm, because it drops the seen ids.
Applet editor
A name, an If block, a Then block. RSS asks for the feed URL. Weather asks for the label, latitude, longitude, timezone, and the millimeters. Calendar asks for the ICS URL. The action is the settings email, or an HTTPS webhook. Save returns to the list with the applet on. The screen has one If block and one Then block. Changing the feed URL clears that trigger’s seen ids and last_poll, so the next poll is a first poll again. Changing the email address leaves the seen ids alone.
Run history
Newest first. Each row is one item: the title, the state, the reason or the status code, and the time. A failed webhook shows Retry when the failure is temporary. A 4xx shows the URL field, and Retry stays off until the URL changes. Opening a row shows the title and the link. The signing secret stays off this screen.
Settings
The owner email, the timezone, and the poll gap. The gap field rejects 5. A logged-out window sees the sign-in form and no applet names.
From a save to one email
You save New post on flaviocopes.com. The list shows it on, and the save returned without fetching. The worker’s next tick takes the lease and reads the feed. The guid https://flaviocopes.com/local-ai-feature/ becomes a skipped run, reason “first poll”. Your inbox has no new mail from Ifso. last_poll is set.
A later tick finds a guid that is not in the seen set. The transaction writes the seen id and a sending run. The mail goes to [email protected]. The subject is the item title, with tags stripped. The body is the title, the link, and the applet name. The run becomes succeeded. You poll again. That guid does not open a second run. You edit the feed item date in a copy of the XML, serve it locally, and poll. Still one run.
You point the feed URL at a closed port. The next tick writes failed, reason “source unreachable”. The seen id for local-ai-feature is still there. You fix the URL. A guid that was new during the outage can still send, because it was never written.
The webhook path is the same commit, then POST. This is the body.
{
"applet": "New post on flaviocopes.com",
"itemId": "https://flaviocopes.com/local-ai-feature/",
"title": "Build a private AI feature with a local model, Ollama and Node.js",
"link": "https://flaviocopes.com/local-ai-feature/"
}
A 200 marks succeeded. A 404 marks failed and permanent, and Retry is off until you change the URL. A timeout marks failed and temporary. Retry sends that same body again and does not insert a run.
Pause, resume, and a stuck send
You pause Rain over 2 mm in Turin. The next ticks do not request the forecast. You resume it the next day. The poll writes a skipped run for today’s date if the sum is at or over 2 mm, reason “paused gap”, and it writes nothing when the sum is under 2 mm. Either way it sends no webhook for the date it just marked. Tomorrow’s date can fire.
On 8 November 2026 that forecast returned precipitation_sum of 0.00 mm for 2026-11-08. A 2 mm applet stays quiet for that date. The call is Open-Meteo’s forecast, with latitude=45.07, longitude=7.69, daily=precipitation_sum, timezone=Europe/Rome, and forecast_days=1. The unit on that field is millimeters. The endpoint asks for no key on this call. If you point the applet at a different forecast URL, keep the same rule: one local date, one decision, and a failed call writes no date.
The calendar check uses two events you host yourself. UID office-hours-2026-11-09 is the first poll, so it is skipped. You add UID office-hours-2026-11-16 and poll again. One email. You change the summary on the first UID and poll. No second email.
A stuck sending row is the crash check. Commit the seen id and the run, then stop the worker before the send. The next tick leaves that run alone. Inbox count stays put. Retry delivers one mail and marks succeeded.
Prompt 1. The list, the pair, and a save that returns
Copy the Astro prompt from Build your own IFTTT. Paste this above it.
Ship the 8-hour row only. Project name: Ifso.
Node.js and Astro. Applets, triggers, actions, seen ids,
runs, and settings live in D1. A scheduled worker polls.
TanStack Start is the same screens and the same rules,
with server functions, a SQL database, and a scheduled
job that is not the page request.
One owner. A logged-out request sees sign-in and no names.
A guessed id from another owner is a 404.
An applet has a name, one trigger, and one action.
Status is on or paused. Pause keeps the rows.
Trigger kinds: rss, weather, calendar. Refuse a fourth.
Actions: email or webhook. Refuse a second action.
The save request writes D1 and returns.
It must not fetch the feed, the calendar, or the forecast.
Settings: owner email [email protected],
timezone Europe/Rome, poll gap 15 minutes.
Reject a gap under 15.
Sample applet name: New post on flaviocopes.com.
No quiet hours, no recipe directory, no OAuth,
no phone app, no second step.
When the plan lists routes and tables, stop.
What to check after the shell
Save the RSS applet with the feed URL filled in. The list shows it on, and the save did not wait on the network. Pause it. The row stays, with the URL still in the editor. Try a fourth trigger kind and a second action. Both are refused. Set the poll gap to 5. It is refused, and 15 remains. Open a logged-out window. You see sign-in, and the applet name is absent.
Prompt 2. A seen-set in the same write as the run
Add the RSS poll on Ifso.
The worker runs on the stored gap. For each applet
that is on, set a two-minute lease if the old lease
is empty or past. A live lease means skip that applet.
Paused applets are not fetched.
Fetch with an 8 second timeout and a 1 MB cap.
On timeout, non-200, or a body over the cap, write
one failed run, reason "source unreachable".
Do not insert or delete seen ids in that case.
Item id is the guid. If the guid is missing, use the link.
The fixture guid is https://flaviocopes.com/local-ai-feature/
from https://flaviocopes.com/rss.xml.
If last_poll is empty, insert every current id as seen
and a skipped run, reason "first poll", set last_poll,
and send nothing.
Otherwise, for a new id, insert the seen id and a run
in state sending in one transaction. The unique key is
trigger id plus item id. A re-dated item with the same
guid inserts nothing.
Delete seen ids older than 90 days.
Changing the feed URL clears that trigger's seen ids
and last_poll.
Done when the fixture guid is a single skipped run
after the first poll, and a second poll does not add
a second run for it.
What to check after the seen-set
Run one poll. History shows a skipped run whose item id is https://flaviocopes.com/local-ai-feature/. The inbox is unchanged. Run the poll again. Still one run for that guid. Serve a copy of the feed with that guid and a new date. Still one run. Point the URL at a closed port, poll, and confirm the skipped run is still there and a new failed run names the source. There is no succeeded email. Change the feed URL back and confirm last_poll is empty again, so the next poll is a first poll.
Prompt 3. Email, a signed webhook, and one run
Add delivery on Ifso.
Send only after the seen id and the sending run commit.
Email goes to the action address. Subject is the item
title with tags removed. Body is the title, the link,
and the applet name. Do not render feed HTML.
Webhook is HTTPS only. Refuse http at save.
POST JSON with applet, itemId, title, and link.
Header X-Ifso-Signature is hex HMAC-SHA256 of the raw
body, using the action secret. Encrypt the secret.
Show it once. History must not contain it.
2xx marks the run succeeded.
4xx marks failed and permanent. Hide retry until the
URL changes.
Timeout or 5xx marks failed and temporary.
Retry posts the same body and updates the same run.
It must not insert a run.
If the worker dies while the run is sending, the next
tick must not send. The row stays sending until retry.
Store title and link on the run. Omit the raw feed.
Status is text: skipped, sending, succeeded, failed.
Done when a retried failure produces one run and one
delivery, and a second poll of the same guid is quiet.
What to check after delivery
Use a local HTTPS endpoint you control. A 200 leaves one succeeded run. Call the poll again. Still one. Return 404. The row is failed, permanent, and Retry is hidden. Change the URL and confirm Retry is back. Stop the worker after the commit and before the POST. The next tick does not call the endpoint. Retry calls it once. Read the history payload and confirm the signing secret is absent. Send the email path to [email protected] for a guid you have not seen yet, and confirm one message.
Prompt 4. Weather, a calendar, and the gap after pause
Add weather and calendar triggers on Ifso.
Weather stores label, latitude, longitude, timezone,
and a threshold in millimeters.
Fixture: Rain over 2 mm in Turin, latitude 45.07,
longitude 7.69, timezone Europe/Rome, threshold 2.
Request https://api.open-meteo.com/v1/forecast
with daily=precipitation_sum, that timezone, and
forecast_days=1. No API key on this call.
The item id is the local date in the response.
Send only when precipitation_sum is greater than
or equal to the threshold, and that date is new.
A reading under the threshold writes no date, so a
later reading that same day can still fire.
A failed request writes no date.
On resume, a date that already qualifies is a
paused-gap skip and sends nothing.
On 8 November 2026 the sum was 0.00 mm, so that
date must not send for this fixture.
Calendar stores an ICS URL. The item id is the
VEVENT UID. The same UID with a new summary stays
quiet. A new UID can send.
Resume after pause: the next poll marks every current
id seen with skipped runs, reason "paused gap",
and sends nothing for those ids. Then it sets last_poll.
Ids that show up on a later poll can send.
Same timeout, size cap, and lease as RSS.
Done when the Turin fixture stays quiet on a day
under 2 mm, and a repeated calendar UID stays one run.
What to check after the other two kinds
Save the Turin applet and poll. If the sum is under 2, history has no succeeded weather run for that date. Raise the threshold above the number in the JSON and poll again. Still quiet. Drop the threshold to 0 and poll. One run for that date. Poll again. Still one. Pause the applet, wait for a tick, and confirm the forecast host saw no request. Resume. The current date is skipped or left alone, and no webhook fires for it.
Host an ICS file with UID office-hours-2026-11-09. The first poll skips it. Add UID office-hours-2026-11-16. One email. Edit the summary of the first UID. No new run.
Failures the agent ships anyway
Two emails for one guid
History shows two succeeded rows, and the item id matches. The seen insert and the run insert were two writes, and the send happened before the unique key existed. Put them in one transaction. The spot check is the unique key, then a second poll.
The archive arrives in your inbox
You enable the applet and the succeeded count is close to the length of the feed. last_poll was set before the skip pass, or the first poll treated every item as new. The fixture guid has to be skipped on a brand-new applet. If it is succeeded, the first-poll branch is missing.
Save waits on the feed
The button spins until flaviocopes.com answers. The poll moved into the request. Move it to the worker. The save check is a fast return with the network to that host blocked.
Pause mails the gap
You paused overnight and the resume tick sends one mail per post from those hours. Cron was still fetching, or resume treated the gap as new. The resume check is a skipped reason of “paused gap” and an empty inbox.
A seen id with no run
The item never arrives, and a later tick skips it because the id is already stored. The seen insert committed alone. After the first poll, every seen id has one run. Query that. A hole means the transaction is split.
The secret sits in the row
The history detail shows the HMAC key, or the webhook URL has the key in the query string and the log line copies it. Encrypt the secret, show it once, and keep logs to applet id, run id, state, and status code.
A second step
The editor grows a filter, a delay, or another action. That is the Zapier card. This screen has one If and one Then.
The 24-hour row, after one real email
Publish a post, or wait for one, and read a single succeeded run for its guid. Then add the clock, the attempts, and the dry run.
Quiet hours write the run as queued and keep the payload. A window such as 22:00 to 07:00 is evaluated in the owner timezone and may cross midnight. A flush sends those runs in order when the window opens, with a gap between them. A weather applet can expire that run and record the reason. An expired run stays in history with that reason. A spring-forward night, where 02:30 is missing, has to leave the queue movable.
Retries are attempt rows under the same run. Network errors and 5xx wait with backoff. A 4xx stops. A timeout that might have delivered still uses the dedup key, so the second attempt cannot send a second mail. History shows one run.
A dry run fetches for real, renders the subject and the JSON, and sends nothing. It does not write seen ids. A dry run that marks the guid seen eats the real post. The check is the same guid firing after you enable the applet.
The scope post is the three-row habit. The prompt post is the line that keeps a connector directory out of the first prompt.
Deploy, then let one feed fire
Ifso wants Astro, D1, and a scheduled worker. Put it on a hostname you already own, ifso.flaviocopes.com. The hosting post is the adapter and the domain. Take a backup of D1 before you delete a paid account. The rows are the applets and the seen ids. Lose those and the next poll mails the archive.
Log a refused save with the field name. Log a poll failure with the applet id and “source unreachable”. Log a delivery with the run id, the state, and the status code. Leave the item body, the webhook URL, and the signing secret out of the log. When a tick skips a live lease, a single count is enough.
Then use it. Point one applet at a feed you already read. Confirm the first poll skips. Wait for a new guid. Read one email and one succeeded row. Pause it and confirm the next tick is quiet.
A missed job of your own is a different card. Cronitor is $10–$49 a month, about 6 hours, and the cron guide is that ping. A scheduled fetch between your own systems is Make, $10–$29 a month, about 4 days.
What to skip on purpose
Leave the service directory, OAuth for hundreds of brands, smart-home controls, teams, and the phone app. Leave automatic backoff and quiet hours until one guid has survived a second poll.
If the rent is “read the blogs”, build the RSS reader. If the rent is “a paid event should POST once”, build the Zapier guide. If the rent is “mail me when this feed, this forecast, or this calendar grows”, build this.
Start now
Go to Build your own IFTTT. Copy the Astro prompt, or TanStack Start if that is what you write.
Tell the agent the first estimate. Five personal triggers, email, and webhook actions. Eight hours.
Save New post on flaviocopes.com. Let the first poll skip https://flaviocopes.com/local-ai-feature/.