SideKick
Book a Consult
architecture
postgres
pg-boss
queues
saas
idempotency

Durable Multi-Touch Email + SMS Sequences Without Redis (pg-boss on Postgres)

August 9, 2026 · 5 min read

By Cass Ryland · CTO

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

  1. You probably don't need Redis. pg-boss turns your existing Postgres into a durable job queue. One less thing to run.
  2. Derive idempotency keys from identity, not time (${entityId}:${step}). Then duplicate delivery is harmless and replays are safe.
  3. Check terminal state at execution, not scheduling. Optimistic scheduling + authoritative execution beats frantic job cancellation.
  4. Log events, derive state. An append-only event table is the cheapest debugging and analytics investment you can make.
  5. 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.


Cass Ryland

Cass Ryland

CTO

Cass leads engineering at SideKick. Fifteen years building production systems; writes about architecture, agents, and the unglamorous parts that make software survive contact with real users.

Building something like this?

We design and ship AI agents and LLM integrations for real products. Tell us what you're working on.

Book a Consult