What you’re building
Not a Zapier clone. You move the few Zaps that use most of your tasks, then drop to a cheaper plan for everything else.
Pick the Zaps first. In Zapier, open Zap history, then Task usage, and look at the last 30 days. Zapier counts a task each time an action step succeeds; triggers, filters and paths are free. So “new signup → add to CRM → send welcome email” uses two tasks per signup, and 600 signups a month is 1,200 tasks. A good Zap to move uses a big share of your tasks, runs between systems with decent APIs, and rarely changes.
A version scoped to what usually drives the bill:
- Your top two or three Zaps as code. The worked example is “new signup → add to CRM → send welcome email”. Each step becomes a job in a Postgres queue, so one failing step doesn’t redo the others.
- Triggers: a database trigger for events in your own app, a small webhook endpoint for events from other tools, and schedules with pg_cron.
- Retries that wait longer each time, and a run log with every attempt, how long it took and its error.
- Failure alerts to Slack or email when a job gives up, plus a heartbeat check so you hear about it if the worker stops running at all.
- A small admin page to see recent runs and retry failed jobs.
Stays on Zapier: every low-volume Zap, anything a non-developer edits, and anything that needs a connector you’d have to build. That long tail costs little on a smaller plan and a lot of your time in code.
Stack
- Supabase for Postgres, Edge Functions and pg_cron. If your app already runs on Supabase, use the same project. Anything customers depend on belongs on Pro ($25/month) so it never pauses; if you’re already on Pro, this adds nothing.
- Lovable or Claude Code to write the functions and the admin page.
- Your CRM’s API, and an email API such as Resend or Postmark for the welcome email. Their free tiers cover low volume.
- Slack (an incoming webhook) or email for alerts, and a free heartbeat monitor such as Healthchecks.io.
Data model
Run this in the Supabase SQL editor. Apart from turning GraphQL off (skip that line if your app uses it), it only adds new tables, functions and one trigger, so it’s safe in a project your app already uses. It creates a job queue and a run log, queues two jobs when someone confirms their signup, and locks the tables down so only your edge functions and admins can see them. If your users live in your own table rather than Supabase Auth, put the same trigger on that table.
-- The app talks to Supabase over REST, not GraphQL. Turning GraphQL off closes an
-- endpoint you don't use and clears two Security Advisor warnings. Skip this line if
-- your app does use GraphQL.
drop extension if exists pg_graphql;
-- People who can see runs and retry jobs
create table public.job_admins (
user_id uuid primary key references auth.users on delete cascade,
created_at timestamptz not null default now()
);
-- One row per piece of work. Add a kind here for each Zap step you move.
create table public.jobs (
id bigint generated always as identity primary key,
kind text not null check (kind in ('crm.add_contact', 'email.welcome')),
payload jsonb not null default '{}',
status text not null default 'queued' check (status in ('queued', 'running', 'done', 'failed')),
attempts integer not null default 0 check (attempts >= 0),
max_attempts integer not null default 5 check (max_attempts between 1 and 10),
run_after timestamptz not null default now(),
locked_at timestamptz,
last_error text,
alerted_at timestamptz,
dedupe_key text unique,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index jobs_due on public.jobs (run_after) where status in ('queued', 'running');
create index jobs_status on public.jobs (status, updated_at desc);
-- One row per attempt: the run log
create table public.job_runs (
id bigint generated always as identity primary key,
job_id bigint not null references public.jobs on delete cascade,
attempt integer not null,
ok boolean not null,
duration_ms integer,
error text,
created_at timestamptz not null default now()
);
create index job_runs_job on public.job_runs (job_id, created_at desc);
-- Privileged helpers live in a schema the API can't reach.
create schema if not exists private;
create function private.is_job_admin() returns boolean
language sql stable security definer set search_path = public as $$
select exists (select 1 from public.job_admins where user_id = (select auth.uid()))
$$;
-- The worked example's trigger: queue both steps once a signup is confirmed.
-- dedupe_key makes sure each person is only ever queued once.
create function private.queue_signup_jobs() returns trigger
language plpgsql security definer set search_path = public as $$
begin
insert into public.jobs (kind, payload, dedupe_key) values
('crm.add_contact', jsonb_build_object('user_id', new.id, 'email', new.email), 'crm:' || new.id),
('email.welcome', jsonb_build_object('user_id', new.id, 'email', new.email), 'welcome:' || new.id)
on conflict (dedupe_key) do nothing;
return new;
end $$;
create trigger on_signup_queue_jobs
after insert or update of email_confirmed_at on auth.users
for each row when (new.email_confirmed_at is not null)
execute function private.queue_signup_jobs();
revoke execute on all functions in schema private from public;
grant execute on function private.is_job_admin() to authenticated;
-- The worker takes up to p_limit due jobs. A run that crashed leaves jobs in
-- 'running'; after 10 minutes they're retried, or failed if they're out of attempts.
create function public.claim_jobs(p_limit integer default 10)
returns setof public.jobs
language sql set search_path = public as $$
update public.jobs
set status = 'failed', last_error = coalesce(last_error, 'Worker stopped mid-run'),
locked_at = null, updated_at = now()
where status = 'running' and locked_at < now() - interval '10 minutes' and attempts >= max_attempts;
update public.jobs j
set status = 'running', locked_at = now(), attempts = j.attempts + 1, updated_at = now()
where j.id in (
select id from public.jobs
where (status = 'queued' and run_after <= now())
or (status = 'running' and locked_at < now() - interval '10 minutes')
order by run_after
limit least(p_limit, 50)
for update skip locked
)
returning j.*;
$$;
-- The worker records each attempt here. Failures wait 3, 9, 27, then 81 minutes
-- before the next try. p_retry = false gives up at once, for errors a retry won't fix.
create function public.finish_job(
p_job_id bigint, p_ok boolean, p_error text default null,
p_duration_ms integer default null, p_retry boolean default true
) returns text
language plpgsql set search_path = public as $$
declare
j public.jobs;
begin
select * into j from public.jobs where id = p_job_id for update;
if not found then
return 'missing';
end if;
insert into public.job_runs (job_id, attempt, ok, duration_ms, error)
values (j.id, j.attempts, p_ok, p_duration_ms, left(p_error, 2000));
if p_ok then
update public.jobs set status = 'done', last_error = null, locked_at = null, updated_at = now()
where id = j.id;
return 'done';
elsif j.attempts >= j.max_attempts or not p_retry then
update public.jobs set status = 'failed', last_error = left(p_error, 2000), locked_at = null, updated_at = now()
where id = j.id;
return 'failed';
else
update public.jobs
set status = 'queued', last_error = left(p_error, 2000), locked_at = null,
run_after = now() + interval '1 minute' * power(3, j.attempts), updated_at = now()
where id = j.id;
return 'queued';
end if;
end $$;
-- Only the worker (secret key) may call these two.
revoke execute on function public.claim_jobs(integer) from public, anon, authenticated;
revoke execute on function public.finish_job(bigint, boolean, text, integer, boolean) from public, anon, authenticated;
grant execute on function public.claim_jobs(integer), public.finish_job(bigint, boolean, text, integer, boolean) to service_role;
-- Row-level security
alter table public.job_admins enable row level security;
alter table public.jobs enable row level security;
alter table public.job_runs enable row level security;
create policy "Admins see the admin list" on public.job_admins for select to authenticated
using ((select private.is_job_admin()));
create policy "Admins see jobs" on public.jobs for select to authenticated
using ((select private.is_job_admin()));
create policy "Admins re-queue failed jobs" on public.jobs for update to authenticated
using ((select private.is_job_admin()) and status = 'failed')
with check ((select private.is_job_admin()) and status = 'queued');
create policy "Admins see runs" on public.job_runs for select to authenticated
using ((select private.is_job_admin()));
-- Table access. New Supabase projects don't grant this automatically.
-- Admins can only touch the columns a retry needs.
revoke all on public.job_admins, public.jobs, public.job_runs from anon, authenticated;
grant select on public.job_admins, public.jobs, public.job_runs to authenticated;
grant update (status, attempts, run_after, alerted_at) on public.jobs to authenticated;
grant select, insert, update, delete on public.job_admins, public.jobs, public.job_runs to service_role;
Then add yourself as an admin after you first sign in: insert into public.job_admins (user_id) select id from auth.users where email = 'you@yourdomain.com';
Run this second block after prompt 4, once run-jobs is deployed with DRY_RUN set to true. Put a long random string in Vault as cron_secret, and the same string in the function’s CRON_SECRET secret.
create extension if not exists pg_cron with schema pg_catalog;
create extension if not exists pg_net with schema extensions;
grant usage on schema cron to postgres;
grant all privileges on all tables in schema cron to postgres;
-- Keep the URL and the shared secret in Vault, not in the job text.
select vault.create_secret('https://your-project-ref.supabase.co', 'project_url');
select vault.create_secret('replace-with-a-long-random-string', 'cron_secret');
-- Call the worker every minute.
select cron.schedule('run-jobs', '* * * * *', $$
select net.http_post(
url := (select decrypted_secret from vault.decrypted_secrets where name = 'project_url') || '/functions/v1/run-jobs',
headers := jsonb_build_object(
'Content-Type', 'application/json',
'x-cron-secret', (select decrypted_secret from vault.decrypted_secrets where name = 'cron_secret')
),
timeout_milliseconds := 55000
);
$$);
-- Every night, delete finished jobs after 30 days and failed ones after 90.
select cron.schedule('prune-jobs', '30 3 * * *', $$
delete from public.jobs
where (status = 'done' and updated_at < now() - interval '30 days')
or (status = 'failed' and updated_at < now() - interval '90 days');
$$);
Build it in 7 prompts
Paste these one at a time into Lovable or Claude Code. Check each step works before moving on; it saves credits. Keep DRY_RUN set to true until you switch off the Zap (step 4 of the move below).
1. Create a Supabase Edge Function called run-jobs. pg_cron calls it every minute, so deploy it with verify_jwt = false and reject any request whose x-cron-secret header doesn't match the CRON_SECRET secret (compare in constant time). Use the project's secret key from the function's environment (SUPABASE_SECRET_KEYS, or SUPABASE_SERVICE_ROLE_KEY on older projects). Each run: claim jobs in batches with the claim_jobs RPC (p_limit 5), run each one through a handlers map keyed by job.kind, and claim the next batch until nothing is due or 40 seconds have passed. Time each attempt and call the finish_job RPC with p_ok, p_error (a short message, never a full response body), p_duration_ms and p_retry (false for errors a retry won't fix, such as HTTP 400, 401, 403, 404 or 422 from the other API). A job whose kind has no handler fails with p_retry false. Overlapping runs are safe because claim_jobs skips jobs another run holds. Return a JSON summary of claimed, done, retried and failed.
2. Add a DRY_RUN mode and the first handler. When the DRY_RUN secret is "true", handlers log exactly what they would send (method, URL and body, with keys removed) and report success without calling anything. Then add the crm.add_contact handler: create or update the contact in my CRM, which is <your CRM>, matching on email so a retry never makes a duplicate. The payload is { user_id, email }; if user_id is set, load the person's name from Supabase Auth with the admin API. Use the key in CRM_API_KEY and a 15-second timeout, and treat 429 and 5xx responses as retryable.
3. Add the email.welcome handler. Send the welcome email through Resend's API (key in EMAIL_API_KEY) from WELCOME_FROM, for example hello@yourdomain.com, with a plain-text and a simple HTML version kept in one template file I can edit. Use "welcome-" plus the job's user_id as an idempotency key if the API supports one, so a retry after a timeout doesn't send it twice. Use a 15-second timeout and treat 429 and 5xx responses as retryable.
4. Add alerts to run-jobs. At the end of each run, select up to 20 jobs with status failed and alerted_at null, send one Slack message to SLACK_WEBHOOK_URL (and an email to ALERT_EMAIL_TO if it's set) listing each job's id, kind, attempts and last_error with a link to /admin/jobs, then set alerted_at on those jobs. If HEARTBEAT_URL is set, send a GET request to it at the end of every run that reached the database, so the monitor emails me when runs stop.
5. Optional, for a Zap that another tool triggers. Create an edge function called inbound with verify_jwt = false. For POST /inbound/stripe, verify the Stripe-Signature header with Stripe's library and STRIPE_WEBHOOK_SECRET; for customer.created events, insert a crm.add_contact job with payload { email } and dedupe_key "stripe:" plus the event id, ignoring duplicates. Return 200 quickly, 400 for a bad signature, and insert nothing for other event types.
6. Add an /admin/jobs page with magic-link sign-in (publishable key only, never a secret key in the browser), visible only to users with a row in job_admins. Show counts by status for the last 24 hours, a table of recent jobs (id, kind, status, attempts, run_after, last_error) with filters for status and kind, and a detail view with every attempt from job_runs. Failed jobs get a Retry button that sets status to queued, attempts to 0, run_after to now and alerted_at to null. Show errors as plain text.
7. Move my next Zap: <describe its trigger and each action step>. Give me the SQL to add a job kind for each step to the jobs.kind check constraint, queue the jobs from the right trigger with a dedupe_key, and add handlers that follow the same pattern as crm.add_contact, including DRY_RUN support.
Security checklist
- Supabase Security Advisor shows no warnings, and RLS is on for every table.
- Only the publishable (anon) key is in the browser. No secret or service role key is in the browser code or the repo; only the edge functions have it, in their environment.
-
run-jobsrefuses any request without the rightx-cron-secretheader (call it once with curl and no header to check). - Every inbound webhook checks the sender’s signature or a shared secret before it queues anything.
- The CRM, email and Slack keys live in edge function secrets, and the cron secret lives in Vault, not in a saved SQL snippet.
- Signed-out users and signed-in non-admins can’t read jobs or runs, and admins can only re-queue failed jobs (try it from the browser console).
- Payloads hold only what a job needs (an id and an email), and old jobs are deleted on schedule.
- You’ve seen a real failure alert, and a heartbeat email after pausing the
run-jobscron job for 10 minutes.
Move off Zapier
You don’t leave Zapier. You move the expensive part and pay less for the rest.
- In Zapier, open Zap history, then Task usage, and note each Zap’s tasks for the last 30 days. Pick the two or three that use the most, as long as they run between systems with decent APIs.
- Write down exactly what each one does: the trigger, any filters, the field mappings and the text of any emails. That list is the spec for its handlers.
- Build with
DRY_RUNset totrueand let it run next to the Zap for a few days. Compare the function logs with Zap history: same events, same data. - Switch over in one go. Turn the Zap off and set
DRY_RUNtofalseat the same time, so nobody gets two welcome emails. Keep the Zap, switched off, for a month as a fallback. - After a full month, measure before you downgrade:
- Tasks per month across the Zaps that stay on Zapier, using your busiest month, not the average.
- Whether any of those Zaps has more than two steps, since the free plan only runs two-step Zaps and checks for new data every 15 minutes.
- Failed jobs per week in your own run log. If it’s more than a handful, fix that before you remove the safety net.
- Pick the smallest plan that covers your busiest month with some room. Under 750 tasks a month fits Pro at $19.99 a month (billed annually), and under 100 tasks with only two-step Zaps fits the free plan. Check whether a downgrade on annual billing takes effect now or at renewal.
After launch
- About 1 hour a month: read the failures in the run log, check whether the APIs you call have announced changes, update dependencies, and check the Supabase advisor.
- Watch for a moved Zap that needs changes every week, or a non-developer who used to own it asking for edits. That’s the moment to move it back to Zapier. If your whole Zapier bill falls under about $20 a month, stop moving Zaps; the rest isn’t worth your time.
Before you build
Check the numbers for your own team first. Stack Scan reads a card statement in your browser and shows whether building beats what you actually pay.
Get new blueprints by email
One email when a new blueprint is out. Tell us which tool you want next.