Send scheduled SMS with Cloudflare Workers
Use a Cloudflare Cron Trigger to call your TNZ SMS Worker. Configure a daily schedule, test without sending, and enable live SMS.
Updated
Use a Cloudflare Cron Trigger to submit an SMS through your existing TNZ SMS Worker on a daily schedule.
This demonstration sends a fixed message to one configured mobile number. It starts in test mode, which checks the request without sending an SMS.
Prerequisite: Complete Cloudflare Workers and Sending SMS, including its curl test.
Keep that SMS Worker deployed. Have its service name and matching WORKER_API_KEY ready, and use the same Cloudflare account. This guide adds a second Worker.
-
Scheduled Worker Daily Cron Trigger
-
SMS Worker Check and Submit
-
TNZ REST API Send SMS
-
Mobile Phone Receives the SMS
1. Create and configure the scheduled Worker
1.1 Create the project
Create a second project alongside tnz-worker:
cd <your-project-folder>
npm create cloudflare@latest -- scheduled-sms
At the prompts, select:
- Hello World example
- Worker only
- JavaScript
- No when asked whether to deploy
1.2 Open the project folder
Run:
cd scheduled-sms
1.3 Configure the service binding
Replace scheduled-sms/wrangler.jsonc with this configuration. If you chose another deployed SMS Worker name, replace tnz-worker in service.
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "scheduled-sms",
"main": "src/index.js",
"compatibility_date": "2026-11-01",
"workers_dev": false,
"preview_urls": false,
"vars": { "SMS_TEST_MODE": "true" },
"services": [
{ "binding": "TNZ_SMS", "service": "tnz-worker", "remote": true }
],
"triggers": { "crons": [] }
}
The empty crons array keeps the schedule disabled while you test. The service binding connects env.TNZ_SMS to your deployed SMS Worker, including during local development.
This Worker has no public send form or HTTP send handler. See Cloudflare's HTTP service binding guide for the Worker-to-Worker connection.
1.4 Configure local secrets
Create scheduled-sms/.dev.vars with these values:
WORKER_API_KEY="YOUR_SMS_WORKER_KEY"
DEMO_MOBILE="YOUR_MOBILE_NUMBER"
- Replace
YOUR_SMS_WORKER_KEYwith the matchingWORKER_API_KEYfrom your SMS Worker. - Replace
YOUR_MOBILE_NUMBERwith your own NZ or AU mobile number. - Check that
.dev.vars*is in.gitignore.
Keep TNZ_AUTH_TOKEN in the SMS Worker. The scheduled Worker only needs the Worker key, recipient and service binding.
2. Add the scheduled handler
Replace scheduled-sms/src/index.js with this code:
View complete JavaScript (39 lines)
export default {
async scheduled(controller, env) {
const scheduledAt = new Date(controller.scheduledTime).toISOString();
if (!env.TNZ_SMS || !env.WORKER_API_KEY || !env.DEMO_MOBILE ||
!["true", "false"].includes(env.SMS_TEST_MODE)) {
console.error(JSON.stringify({ scheduledAt, error: "Check binding, secrets and SMS_TEST_MODE" }));
return;
}
const test = env.SMS_TEST_MODE === "true";
try {
const response = await env.TNZ_SMS.fetch("https://sms.internal/send-sms", {
method: "POST",
headers: {
Authorization: `Bearer ${env.WORKER_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
to: env.DEMO_MOBILE,
message: "Demo: your scheduled Cloudflare SMS.",
test
})
});
if (!response.ok) {
console.error(JSON.stringify({ scheduledAt, status: response.status,
error: "SMS not confirmed; check before retrying" }));
return;
}
const result = await response.json();
if (result.accepted !== true || result.test !== test ||
typeof result.messageId !== "string" || !result.messageId) {
throw new Error("Unexpected response");
}
console.log(JSON.stringify({ scheduledAt, accepted: true, test, messageId: result.messageId }));
} catch {
// Do not retry: TNZ may have accepted the first submission.
console.error(JSON.stringify({ scheduledAt, error: "SMS outcome unknown; check before retrying" }));
}
}
};
Cloudflare invokes scheduled() when the Cron Trigger fires. The handler sends one request to the SMS Worker and records the submission result.
The URL https://sms.internal/send-sms supplies the path for the service binding. You do not need to register that domain.
The code records the scheduled time, test mode and returned message ID. It excludes keys, recipient numbers and message text from logs.
3. Test the handler locally
Keep SMS_TEST_MODE set to the string "true". Stop any other local Worker using port 8787 before you start.
- Run
npx wrangler dev --ip 127.0.0.1 --port 8787fromscheduled-sms. Sign in to Cloudflare if prompted. - Open
http://127.0.0.1:8787/cdn-cgi/local/scheduledin your browser once to invoke the local scheduled handler. - Check the Wrangler terminal for the SMS submission result. The browser's handler response alone does not confirm SMS acceptance.
This test runs immediately without sending an SMS. It does not wait for the daily schedule.
See Cloudflare's Cron Trigger testing instructions if your local invocation does not run.
4. Deploy and enable the schedule
4.1 Deploy without a schedule
Stop local development with Ctrl+C. Keep the crons array empty and test mode enabled while you deploy:
npx wrangler deploy
npx wrangler secret put WORKER_API_KEY
npx wrangler secret put DEMO_MOBILE
- Run
npx wrangler deployfromscheduled-sms. - Run the
WORKER_API_KEYcommand and enter the matching value from.dev.vars. - Run the
DEMO_MOBILEcommand and enter the mobile number from.dev.vars.
Local secrets are not automatically copied to the deployed Worker.
4.2 Set the daily schedule
Then replace the empty triggers setting in wrangler.jsonc with:
"triggers": { "crons": ["0 9 * * *"] }
This example runs daily at 09:00 UTC. Cloudflare Cron Triggers use UTC. Convert your intended local time to UTC and account for daylight saving changes.
4.3 Deploy and check the scheduled test
npx wrangler deploy
npx wrangler tail
Keep the log session open around the scheduled time. Trigger changes can take up to 15 minutes to propagate. Check the deployment's trigger configuration if no run appears.
The deployed schedule still uses test mode. Confirm its result in the logs before enabling live sends.
5. Enable live SMS and stop the demonstration
5.1 Enable live sends
Change the configured variable to "SMS_TEST_MODE": "false" and run npx wrangler deploy again.
5.2 Check the live result
At the next scheduled run, check for accepted: true and test: false in the logs. Check your phone for Demo: your scheduled Cloudflare SMS.
Acceptance confirms submission, not handset delivery. Use the returned messageId to check delivery.
Troubleshooting
| Problem | What to check |
|---|---|
| No scheduled run | Check the deployed cron expression, UTC time and propagation delay. Local development requires the manual test invocation. |
| Configuration error | Check TNZ_SMS, both secrets and the exact string "true" or "false" for SMS_TEST_MODE. |
The handler logs errors and returns without retrying. Inspect the log fields even when the Cron invocation completes. For SMS errors, use the shared troubleshooting guide. For an uncertain result, follow the retry checks before invoking the handler again.
Before using this in production
This example has no database, recipient list or duplicate protection. It demonstrates a recurring trigger, not a complete reminder system.
- For reminders, read due records from your application and check their status and messaging permission before sending.
- Store a unique event or schedule-run ID and processing state. Prevent concurrent or repeated invocations from sending the same intended message twice.
- Store the returned message ID and resolve uncertain results before retrying. A later scheduled run is a new submission, not a retry of the previous one.
- Monitor failures and message usage. Follow the shared SMS Worker production controls.
Topics: Developers SMS