Pricing Docs API Reference Blog About Request Demo

Sending Welcome Emails to New Users with Scheduler0

Scheduler0 Team

A welcome email looks like the easiest feature in your product. A user signs up, you send a friendly "thanks for joining" message, done. Then reality arrives. You do not want to send it during the signup request, because a slow email provider should not slow down your sign-up flow. You probably do not want to send it at 3 AM in the user's timezone. You almost certainly want a sequence — a welcome now, a "here's how to get started" two days later, a "need help?" nudge on day seven — and you very much do not want to email someone twice because a worker restarted mid-send.

In other words, "send a welcome email" is a scheduling problem wearing a marketing hat. This post shows how to model it cleanly with Scheduler0: a one-shot welcome on signup, a timezone-aware drip sequence, retries with idempotency so nobody gets double-emailed, and a natural-language path so your growth team can edit the sequence without shipping code.

Why the naive versions break

Most teams reach for one of three approaches first, and each has a failure mode worth naming up front:

  1. Send inline during the signup request. Simple, but now your registration latency includes your email provider's latency, and a provider hiccup turns into a failed signup. You also cannot delay or sequence anything.
  2. A crontab that scans for "users created in the last 5 minutes". This works until a run is missed (deploy, crash, box reboot) and a cohort of users silently never gets welcomed. There is no retry, no idempotency, and "send at 9 AM the user's local time" becomes a pile of timezone math in your query.
  3. A background queue with a delay. Closer, but you have now taken on retry policy, dead-lettering, idempotency keys, and visibility yourself — and a queue does not natively understand "every weekday at 9 AM in the recipient's timezone."

Scheduler0 gives you the scheduling primitives — delayed one-shots, per-job timezones, first-class retryMax, SHA-256 idempotency, and execution analytics — so the only thing your code owns is the actual "render and send the email" step.

The model: project, executor, job

Three concepts, in order:

  • A project is your isolation boundary. One project per product (or per environment) gives you scoped credentials and analytics.
  • An executor is how a job runs. For welcome emails the simplest executor is a webhook_url pointing at a small endpoint in your app that sends one email.
  • A job is when it runs plus the data payload handed to the executor.

So the flow is: signup happens → your app schedules a job → at the scheduled time Scheduler0 calls your webhook with the payload → your webhook sends the email. Your registration request returns immediately; the email is somebody else's problem (Scheduler0's).

All requests below use the REST API with your account headers. Set them once:

export BASE="https://api.scheduler0.com"
export H_CT="Content-Type: application/json"
export H_KEY="X-API-Key: $KEY"
export H_SEC="X-Secret-Key: $SECRET"
export H_ACC="X-Account-ID: $ACCT"

Step 1 — Create the project and the email executor

Create a project for the product (you do this once):

curl -X POST "$BASE/v1/projects" \
  -H "$H_CT" -H "$H_KEY" -H "$H_SEC" -H "$H_ACC" \
  -d '{
    "name": "lifecycle-email",
    "description": "Transactional and lifecycle emails",
    "createdBy": "growth-eng"
  }'

Then register a webhook executor that points at an endpoint in your app. That endpoint is the only email-sending code you have to write — it receives the job payload and calls your provider (SendGrid, Postmark, SES, Resend, whatever):

curl -X POST "$BASE/v1/executors" \
  -H "$H_CT" -H "$H_KEY" -H "$H_SEC" -H "$H_ACC" \
  -d '{
    "name": "send-welcome-email",
    "type": "webhook_url",
    "webhookUrl": "https://api.yourapp.com/internal/send-email",
    "webhookSecret": "whsec_rotate_me",
    "webhookMethod": "POST",
    "createdBy": "growth-eng"
  }'

The webhookSecret is sent so your endpoint can verify the call really came from Scheduler0 before sending anything. Note the returned projectId and executorId — you will reference them when creating jobs.

If you would rather not run a webhook at all, set type to cloud_function and point it at a Lambda, Azure Function, or GCP Function with cloudProvider, region, and cloudResourceUrl. The job model below is identical either way.

Step 2 — Schedule the welcome email on signup

When a user registers, schedule a one-shot job a few seconds in the future. The schedule is decoupled from your signup request, so registration never blocks on the email:

curl -X POST "$BASE/v1/jobs" \
  -H "$H_CT" -H "$H_KEY" -H "$H_SEC" -H "$H_ACC" \
  -d '[{
    "projectId": 101,
    "executorId": 55,
    "spec": "@every 10s",
    "data": "{\"template\":\"welcome\",\"userId\":\"usr_8821\",\"email\":\"ada@example.com\",\"dedupeKey\":\"welcome:usr_8821\"}",
    "retryMax": 5,
    "timezone": "UTC",
    "createdBy": "signup-svc"
  }]'

A few details that matter:

  • The data field is an opaque JSON string that Scheduler0 hands to your webhook verbatim. Put everything your sender needs in there — the template name, the user id, the address, and a stable dedupeKey.
  • retryMax: 5 means a transient failure (your provider returns a 5xx, a timeout) is retried automatically up to five times. You are not writing retry loops.
  • Because this is a one-shot, your webhook should delete the job after a successful send so it does not fire again. Call DELETE /v1/jobs/{id} with the id returned at creation time.

Why you will not double-email anyone

This is the part that justifies using a scheduler instead of sleep plus a queue. Every execution Scheduler0 dispatches carries a unique id derived from the job, project, last execution time, and next scheduled time:

uniqueId = SHA256(projectId + "-" + jobId + "-" + lastExecutionDate + "-" + nextExecutionTime)

That fingerprint is committed to the execution log before your webhook is called, which is how the cluster avoids re-dispatching a run it already dispatched after a crash or a leader change. On your side, treat the dedupeKey in the payload (or that uniqueId) as a unique constraint in your sent_emails table. The combination — Scheduler0 not re-dispatching, plus your endpoint refusing to send a duplicate — gives you effectively exactly-once welcome emails even across node restarts and retries.

Step 3 — Send at a humane local time

"Send immediately" is fine for the transactional welcome. The follow-ups, though, should land when the user is awake. Scheduler0 stores the timezone on the job itself, so "9 AM" means 9 AM where the user is — no conversion logic in your code, and no separate deployment per region.

Here is a three-message onboarding drip created as a batch. Each job is scheduled in the user's timezone:

curl -X POST "$BASE/v1/jobs" \
  -H "$H_CT" -H "$H_KEY" -H "$H_SEC" -H "$H_ACC" \
  -d '[
    {
      "projectId": 101, "executorId": 55,
      "spec": "0 0 9 * * *",
      "startDate": "2026-06-01T00:00:00Z",
      "endDate": "2026-06-01T23:59:59Z",
      "data": "{\"template\":\"getting_started\",\"userId\":\"usr_8821\",\"email\":\"ada@example.com\",\"dedupeKey\":\"getting_started:usr_8821\"}",
      "timezone": "America/New_York",
      "retryMax": 5, "createdBy": "lifecycle-svc"
    },
    {
      "projectId": 101, "executorId": 55,
      "spec": "0 0 9 * * *",
      "startDate": "2026-06-06T00:00:00Z",
      "endDate": "2026-06-06T23:59:59Z",
      "data": "{\"template\":\"tips_and_tricks\",\"userId\":\"usr_8821\",\"email\":\"ada@example.com\",\"dedupeKey\":\"tips:usr_8821\"}",
      "timezone": "America/New_York",
      "retryMax": 5, "createdBy": "lifecycle-svc"
    },
    {
      "projectId": 101, "executorId": 55,
      "spec": "0 0 9 * * *",
      "startDate": "2026-06-08T00:00:00Z",
      "endDate": "2026-06-08T23:59:59Z",
      "data": "{\"template\":\"need_help\",\"userId\":\"usr_8821\",\"email\":\"ada@example.com\",\"dedupeKey\":\"help:usr_8821\"}",
      "timezone": "America/New_York",
      "retryMax": 5, "createdBy": "lifecycle-svc"
    }
  ]'

Two things worth calling out. The spec uses the six-field cron form (second minute hour day-of-month month day-of-week), so 0 0 9 * * * reads as "second 0, minute 0, hour 9, every day." The startDate/endDate window narrows each "every day at 9 AM" schedule down to a single day, which is how you turn a recurring spec into a one-shot that fires on a specific calendar date — day 2 and day 7 of the user's onboarding, sent at 9 AM their time.

If the user signed up in Berlin, you would set "timezone": "Europe/Berlin" and change nothing else. The same project, the same executor, the same code — the scheduler handles the offset and DST for you.

What happens if the cluster is down at 9 AM?

It still sends. If a node restarts during the send window, Scheduler0 recovers overdue executions on startup as long as the next scheduled time has not passed. A "day 2 at 9 AM" email does not silently vanish because you deployed at 8:58. Compare that to the crontab-scans-recent-users approach, where a missed run is a permanently un-welcomed cohort.

Step 4 — Let non-engineers edit the sequence

The growth team will want to change the cadence — "make the tips email day 3 instead of day 2", "add a re-engagement email at week four." You do not want that to be a code change every time. The /v1/prompt endpoint turns plain English into a structured job spec your app can review and store:

curl -X POST "$BASE/v1/prompt" \
  -H "$H_CT" -H "$H_KEY" -H "$H_SEC" -H "$H_ACC" \
  -d '{
    "prompt": "Welcome new users now, then email getting-started on day 2 and help on day 7 at 9am",
    "purposes": ["onboarding"],
    "events": ["signup"],
    "recipients": ["new_user"],
    "channels": ["email"],
    "timezone": "America/New_York"
  }'

The response is a set of jobs with generated cron expressions and run times. You can render those in your dashboard for a human to confirm before committing them to the jobs API. That is the difference between "engineering owns the email schedule" and "growth owns the email schedule, engineering owns the send" — which is where you want to be.

Step 5 — See what actually went out

Lifecycle email is exactly the kind of thing that quietly breaks and nobody notices for a week. Scheduler0's execution endpoints give you the visibility without scraping logs:

  • GET /v1/executions — per-execution log with state, node, and retry counters. This is where you confirm a specific user's welcome email fired and succeeded.
  • GET /v1/executions/totals — scheduled / success / failed counts. A welcome-email success rate that dips is a paging-worthy signal.
  • GET /v1/executions/analytics — executions bucketed over a date range, which feeds a simple "emails sent per day, success rate" chart.

If your welcome-email success rate drops from 99% to 80%, that is your provider having a bad day — and you find out from a dashboard, not from a support ticket.

How it fits together

   signup request
         |
         v
   POST /v1/jobs  (one-shot welcome + day-2/day-7 drip)
         |
         v
   Scheduler0 (Raft, HA)
     - per-job timezone (9 AM local)
     - retryMax on provider failures
     - SHA-256 idempotency key per execution
     - recovers overdue runs after restart
         |
         | HTTPS at scheduled time
         v
   your /internal/send-email webhook
     - verify webhookSecret
     - check dedupeKey in sent_emails
     - call email provider
     - on one-shot success: DELETE /v1/jobs/{id}
         |
         v
   execution log  ->  /v1/executions/totals + /analytics  ->  dashboard

The scheduler is never in the path of your signup request, and it is the only place retry, idempotency, timezones, and recovery live. Your code shrinks to "render a template and call a provider."

Operational notes

A few things worth doing on day one:

  • Make your webhook idempotent. Store dedupeKey (or the execution uniqueId) with a unique constraint. This is your last line of defense and it is cheap.
  • Verify webhookSecret on every call. Your send endpoint can trigger real emails; do not leave it open.
  • Delete one-shots after they succeed. The transactional welcome and each dated drip email are one-shots — clean them up so they cannot re-fire and so your job list stays readable.
  • Set timezone per user, not globally. Storing the recipient's timezone on the job is the entire reason "9 AM local" is free instead of a bug farm.
  • Pick retryMax for the provider, not a default. Five is a reasonable starting point for email; idempotent send endpoints can safely take more.
  • One project per environment. Keep staging from emailing your real users by isolating credentials and jobs per project.

Closing

A welcome email is a deceptively good first Scheduler0 use case: it is user-facing, it must not double-send, it should respect the recipient's clock, and it begs to be a sequence rather than a single message. Once you model it as "schedule a job, let a webhook send the mail," every other lifecycle email — trial-ending reminders, re-engagement nudges, renewal notices — is the same shape with a different template name and a different delay.

The full surface — jobs, executors, the prompt API, executions analytics, and self-hosting — is in the documentation and the API reference. Start with the one-shot welcome, add the timezone-aware drip when you are ready, and hand the cadence to your growth team via the prompt endpoint when they inevitably ask to change it.