Here's a product that is almost entirely a scheduling problem: QuoteRescue. A contractor pastes an estimate they already sent; the system runs a multi-touch email + SMS sequence with a one-click deposit link until the customer pays, declines, or goes silent. The goal is to rescue roughly 1 in 5 unanswered quotes without the contractor chasing anyone.
The interesting engineering is not the emails. It is running a reliable, resumable, idempotent multi-day sequence — and doing it without adding Redis, a separate worker fleet, or a cron server. Here is the whole design.
The sequence
T+0 email "Your quote from {Company}" → link to /q/[token]
T+2 email friendly reminder
T+4 SMS short nudge (skipped if tenant SMS not 10DLC-approved)
T+7 email + SMS "last check-in" pair
T+10 end status flips to no_response if still active
Five terminal states end a sequence early: won (deposit paid or manually marked), lost (declined or manually marked), and no_response (reached T+10 still active). Once an estimate hits a terminal state, every remaining touchpoint must become a no-op.
Why pg-boss, not Redis + BullMQ
The default reach for "delayed jobs" is Redis and a queue library. For a product on Supabase's free tier, that is a second piece of stateful infrastructure to provision, monitor, and pay for. Instead we use pg-boss, which implements a job queue inside the Postgres database you already have using SKIP LOCKED for concurrency-safe dequeues.
One database. No Redis. Jobs, tenant data, and the event log live in the same Postgres, which means a job and the row it acts on are one transaction away from consistency. For a system sending a few thousand messages a day, this is not a compromise — it is less to break.
Idempotency is the whole game
A delayed-job system will deliver a job twice. The network hiccups, the worker restarts after picking up a job but before acking it, a retry fires. If "send the T+4 SMS" runs twice, the customer gets two texts and trusts the contractor less. So every touchpoint carries a deterministic idempotency key:
const idempotencyKey = `${estimate.id}:${tp.step}`; // e.g. "est_abc123:T+4"
Because the key is derived from what the message is (this estimate, this step) rather than when it ran, a duplicate job produces the same key, and the send is deduplicated at the adapter boundary. Re-running the entire sequence is safe. Replaying a job after a crash is safe. This single discipline removes an entire class of "customer got spammed" incidents.
Terminal-state guards, checked late
The naive bug: you schedule T+7 on day 0, the customer pays on day 3, and T+7 still fires because it was already queued. The fix is to check disposition at execution time, not schedule time:
// Defensive: if the estimate reached a terminal disposition by now, do nothing.
if (isTerminal(estimate.status)) {
return { skipped: true, reason: `terminal_${estimate.status}` };
}
Every touchpoint re-reads the estimate's current status the moment before it sends. Scheduling is optimistic; execution is authoritative. This means you never have to "cancel" scheduled jobs in a panic — a paid estimate simply short-circuits its own remaining touchpoints. (Cancellation is still wired up as an optimization, and it's idempotent too: re-cancel is a no-op.)
The event log is the source of truth
Every meaningful thing that happens is an append-only row in public.events:
email.sent · email.delivered · email.opened · email.bounced · email.complained
sms.sent · sms.delivered · sms.failed
quote.viewed · deposit.session_created · deposit.paid
estimate.disposition_set · sequence.scheduled · sequence.completed
State is derived from events, not the other way around. Did this quote get viewed before the deposit? Join quote.viewed and deposit.paid. Why did a sequence stop at T+4? There's a sequence.completed row with the terminal reason. When a contractor asks "did my customer actually get the text," you have a delivery receipt, not a shrug. An event log costs almost nothing to write and pays for itself the first time you debug production.
Compliance is not optional plumbing
US SMS has a legal gate: A2P 10DLC. You cannot send application-to-person texts from an unregistered brand/campaign. So the SMS adapter has three outcomes, not two:
type SmsResult =
| { status: "sent"; sid: string }
| { status: "failed"; error: string }
| { status: "skipped_opt_out" }; // recipient opted out — a success, not an error
If a tenant isn't 10DLC-approved, the T+4 SMS step is skipped and the sequence continues on email alone. Opt-outs are stored and honored forever — a skipped opt-out is a correct outcome, not a failure to retry. Building this gate in from the start is far cheaper than retrofitting it after a carrier violation.
What to copy
- You probably don't need Redis.
pg-bossturns your existing Postgres into a durable job queue. One less thing to run. - Derive idempotency keys from identity, not time (
${entityId}:${step}). Then duplicate delivery is harmless and replays are safe. - Check terminal state at execution, not scheduling. Optimistic scheduling + authoritative execution beats frantic job cancellation.
- Log events, derive state. An append-only event table is the cheapest debugging and analytics investment you can make.
- Model compliance skips as first-class outcomes, not errors — especially for SMS.
The product is "send some follow-ups." The engineering is a resumable, idempotent, auditable state machine that happens to run on the database you already had. That's the boring architecture that lets a solo contractor trust it with their revenue.
Need a reliable automation backend built on infrastructure you already run? Let's talk.
