Sending Welcome Emails to New Users with Scheduler0
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:
- 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.
- A
crontabthat 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. - 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_urlpointing 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
datafield 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 stablededupeKey. retryMax: 5means 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 executionuniqueId) with a unique constraint. This is your last line of defense and it is cheap. - Verify
webhookSecreton 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
timezoneper 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
retryMaxfor 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.
