How to Build Reliable Reminders and Follow-Ups

Last updated: April 22, 2026

Reminders look simple: schedule something now, deliver it later. The complexity is in the gap. By delivery time, the user may have acted, the timezone may matter, and the reminder may be one step of many.

Scheduling decides when to check; the handler decides whether to act.

Four design principles for reliable reminders

1. A scheduled job carries only an identifier

When the triggering event happens (signup, invitation sent, order placed), schedule the reminder immediately. Don’t batch. Don’t wait for a poller to pick it up later. The payload should carry only the identifier needed to look up current state at delivery time.

Payloads should point, not snapshot.

// Bad: embed business state in the payload
await scheduler.schedule({
  postAt: '2026-03-15T09:00:00Z',
  path: '/webhooks/trial-reminder',
  data: {
    userId: 'user_123',
    trialEndDate: '2026-03-16T00:00:00Z',
    currentPlan: 'trial',
  },
});
// Good: carry only the identifier and reminder type
await scheduler.schedule({
  postAt: '2026-03-15T09:00:00Z',
  path: '/webhooks/trial-reminder',
  data: { userId: 'user_123', reminderType: 'trial-ending-24h' },
});

Reminder delivery windows are often long. Hours, days, sometimes weeks. Embedded business state is usually stale by the time the reminder runs. The payload is a pointer, not a snapshot.

2. The handler looks up state and decides

When the scheduled job fires, the handler reads the identifier from the payload, looks up current state, and decides: act or skip. This state check is what makes reminders reliable and correct.

app.post('/webhooks/trial-reminder', async (req, res) => {
  const { userId, reminderType } = req.body.data;
  const user = await db.getUser(userId);

  if (!user || user.trialExtended || user.subscribed) {
    return res.status(200).json({ status: 'skipped' });
  }

  await sendTrialReminder(user, reminderType);
  return res.status(200).json({ status: 'sent' });
});

The happy path for reminders includes no-ops. A reminder that skips because the user already acted is not a failure. It’s the system behaving correctly.

The core pattern: check current state, then act or skip.

Because reliable schedulers generally deliver at-least-once, the handler should also be safe to run twice. In many reminder flows, duplicate delivery is harmless because a second attempt finds state already changed and no-ops.

3. Timezone belongs at scheduling time

If the requirement is “fire at 9am for this user,” that’s a scheduling concern, not a handler concern. DST and timezone conversion belong at scheduling time.

await scheduler.schedule({
  postAtLocal: '2026-03-15T09:00:00',
  timezone: 'America/New_York',
  path: '/webhooks/morning-reminder',
  data: { userId: 'user_123' },
});

Most reminders have a human-facing time: a morning nudge, a 9am deadline warning, an end-of-day summary. If the handler is doing timezone math, the concern is probably misplaced.

Not every reminder is local-time-based. If the requirement is a single global deadline, schedule in UTC. If the requirement is truly user-local, schedule in local time with the timezone captured up front.

4. Each step is its own scheduled job

Multi-step reminder flows are common: day 1, day 3, day 7. For conditional follow-up sequences, each step should stand on its own scheduled job. The handler for step N schedules step N+1 only after acting.

app.post('/webhooks/onboarding-step-1', async (req, res) => {
  const { userId } = req.body.data;
  const user = await db.getUser(userId);

  if (user.profileCompleted) {
    return res.status(200).json({ status: 'skipped' });
  }

  await sendOnboardingEmail(user, 'day-1');

  await scheduler.schedule({
    postIn: '48h',
    path: '/webhooks/onboarding-step-2',
    data: { userId },
  });

  return res.status(200).json({ status: 'sent' });
});

This keeps the sequence local and understandable:

  • If step 1 skips, step 2 is never scheduled.
  • If step 2 fails, earlier steps are unaffected.
  • If the user acts after step 1, later steps can naturally no-op or never be created.

For conditional reminder chains, advancing one step at a time usually produces cleaner behavior than pre-scheduling the whole sequence.

flowchart LR
A[Triggering event]
B[Scheduled job<br/>carries identifier]
C[Handler reads identifier<br/>looks up current state]
D[Act]
E[Skip]
A -->|schedule| B
B -->|fires| C
C -->|state relevant| D
C -->|state changed| E
D -.->|optionally schedule next step| B

classDef default fill:#111113,stroke:#27272a,stroke-width:1px,color:#fafafa,rx:6,ry:6
classDef accent fill:#111113,stroke:#7CFEF0,stroke-width:1.5px,color:#fafafa,rx:6,ry:6
classDef outcome fill:#0a0a0b,stroke:#3f3f46,stroke-width:1px,color:#a1a1aa,rx:6,ry:6
class C accent
class D,E outcome
The four design principles in one flow: trigger, schedule with identifier, handler checks state, act or skip, optionally schedule next step.

What’s left to build

The four principles define the shape. The rest is machinery:

  • Scheduling each reminder at the trigger moment
  • Retries and backoff when delivery fails
  • Delivery visibility and debugging
  • Cancellation and cleanup
  • Timezone and DST correctness

That’s where the implementation starts to feel less like one reminder and more like a timing subsystem.

Why this becomes a subsystem

A cron job that polls a reminders table is the usual starting point. It works for longer than people expect, which is exactly why the maintenance burden often arrives late.

A few things tend to accumulate:

  • Per-user timing becomes state management. Every reminder is a row, every cancellation is a flag, every retry is another state transition.
  • Polling forces a precision tradeoff. Poll frequently and do more work; poll less and accept drift. A one-minute cadence means up to 59 seconds late.
  • Failures need a path. Delivery failures need attempt tracking, retry policy, and somewhere to go when retries are exhausted. You build it or you lose reminders.
  • Visibility doesn’t appear by accident. “Why didn’t this send?” and “Which deliveries failed yesterday?” become product and support questions, not just infrastructure ones.
  • Each new reminder type compounds the work. Trial expiry, abandoned cart, invitation follow-up, support nudge, re-engagement. By the fifth concern, you’re maintaining five polling jobs with five slightly different failure modes.

Put together, this is the timing subsystem you didn’t set out to build.

Building this with Posthook

Posthook is a webhook scheduler that maps cleanly to the four design principles above. You schedule a reminder when the triggering event happens, Posthook delivers the webhook later, and your handler still owns the state check: act or skip.

Three common reminder shapes, each implemented with Posthook. The handler pattern stays the same across all three: look up current state, then act or skip.

Handlers use posthook.signatures.parseDelivery from the SDK to verify the signature and parse the payload. Configure express.raw({ type: '*/*' }) so the raw body is available.

Booking or appointment reminder

Remind a customer the day before their booking. When the booking is created, schedule the reminder for 24 hours before it, in the customer’s local time. When the hook fires, check whether the booking still stands.

import Posthook from '@posthook/node';

const posthook = new Posthook('phk_...', { signingKey: 'phsk_...' });

// When a booking is created, schedule a reminder for 24h before it
await posthook.hooks.schedule({
  path: '/webhooks/booking-reminder',
  postAtLocal: booking.reminderLocalTime, // computed 24h before booking.startsAt
  timezone: booking.userTimezone,
  data: { bookingId: booking.id },
});
app.post('/webhooks/booking-reminder', async (req, res) => {
  const delivery = posthook.signatures.parseDelivery(req.body, req.headers);
  const booking = await db.getBooking(delivery.data.bookingId);

  if (!booking || booking.cancelled || booking.rescheduledAt) {
    return res.status(200).json({ status: 'skipped' });
  }

  await sendBookingReminder(booking);
  return res.status(200).json({ status: 'sent' });
});

User-set send times

Let your users pick exactly when a reminder fires. The user configures a time and timezone; your app schedules at that local time.

// When the user saves their reminder preferences
await posthook.hooks.schedule({
  path: '/webhooks/user-reminder',
  postAtLocal: userConfig.localTime, // e.g., '2026-03-15T09:00:00'
  timezone: userConfig.timezone,
  data: { userId: user.id, reminderId: reminder.id },
});

The handler follows the same pattern: read the reminder ID, look it up, skip if disabled or already sent.

Support case follow-up with escalation

If a ticket isn’t resolved in 48 hours, nudge the assignee. If it’s still open another 24 hours later, escalate. Each step is its own hook; each schedules the next only if it acted.

// When a ticket is created
await posthook.hooks.schedule({
  path: '/webhooks/ticket-check-48h',
  postIn: '48h',
  data: { ticketId: ticket.id },
});
app.post('/webhooks/ticket-check-48h', async (req, res) => {
  const delivery = posthook.signatures.parseDelivery(req.body, req.headers);
  const ticket = await db.getTicket(delivery.data.ticketId);

  if (!ticket || ticket.resolved) {
    return res.status(200).json({ status: 'skipped' });
  }

  await nudgeAssignee(ticket);

  // Still unresolved after another 24h? escalate.
  await posthook.hooks.schedule({
    path: '/webhooks/ticket-escalate',
    postIn: '24h',
    data: { ticketId: ticket.id },
  });

  return res.status(200).json({ status: 'sent' });
});

Posthook doesn’t change the core pattern. The handler still checks current state. A skipped reminder is still a valid outcome. Cancellation with posthook.hooks.delete(hookId) is still cleanup, not correctness.

What Posthook supplies is the surrounding machinery: scheduling precision, retries, delivery visibility, timezone-aware scheduling, and operational tooling around each scheduled action.

Compared to building it yourself

The four design principles don’t change. The work around them does.

CapabilityDIY (cron + database)Posthook
Per-user schedulingInsert a row, poll for due rows on a cron cadenceOne API call; fires at the scheduled time
Retry on failureBuild retry logic, track attempts, handle backoffRetries with backoff, jitter, and per-hook overrides
Delivery visibilityQuery your own logs or build a dashboardPer-hook status (pending, retry, completed, failed) with attempt history
Failure alertingBuild monitoring around the cron jobPer-endpoint anomaly detection with alerts via email, Slack, or webhook
Timezone and DSTConvert to UTC manually, handle transitions in codepostAtLocal + timezone with automatic DST handling
CancellationUpdate a flag; handler checks before actingposthook.hooks.delete(hookId) for cleanup
InfrastructureCron job, database table, polling query, retry logic, monitoringAPI key, endpoint

When not to use Posthook

Coarse recurring digests. “Send a daily summary email to all users” is a cron job that queries for eligible users. Posthook fits better when each user, order, or event creates its own scheduled job with its own deadline.

Complex branching workflows. “If the user opened the email, send variant B; otherwise send variant C” is workflow orchestration. Consider Inngest or Trigger.dev or Temporal. Posthook schedules the action; it doesn’t own branching logic.

Fixed-cadence infrastructure jobs. For jobs that run on a regular schedule and don’t need per-event timing, Vercel Cron, GitHub Actions scheduled workflows, or a regular cron entry may be simpler.

Getting started

Install the SDK:

npm install @posthook/node

Most reminder flows need one of three scheduling modes:

// Relative: fire 48 hours from now
await posthook.hooks.schedule({
  path: '/webhooks/nudge',
  postIn: '48h',
  data: { userId: 'user_123' },
});

// Exact UTC: fire at a specific moment
await posthook.hooks.schedule({
  path: '/webhooks/deadline',
  postAt: '2026-05-01T12:00:00Z',
  data: { projectId: 'proj_abc' },
});

// Local time with timezone: fire at 9am in the user's timezone
await posthook.hooks.schedule({
  path: '/webhooks/morning-reminder',
  postAtLocal: '2026-03-15T09:00:00',
  timezone: 'America/New_York',
  data: { userId: 'user_123' },
});

Point the path at a handler on your own service. Posthook calls it when the hook fires, with the payload you supplied. The handler owns the decision from there.

For the full API, including per-hook retry overrides, cancellation, and replay, see the Posthook docs.

Frequently asked questions

Ready to get started?

Free plan includes 1,000 hooks/month. No credit card required.