Cloudflare Cron → Discord Webhook
Send scheduled notifications from a Cloudflare Worker Cron Trigger to a Discord channel, with an architecture that's safe to extend into real domain logic later.
Prerequisite: a working Workers skeleton with scheduled handler wired (see the cloudflare-workers-deploy-skeleton skill).
When to use
- Daily / hourly / weekly Discord notifications (reminders, summaries, health checks)
- Any scheduled outbound message from Workers to any webhook-style recipient (Discord is the example; the architecture transfers to Slack, Teams, etc. by swapping the HTTP contract)
- As a walking skeleton before real domain logic is ready — skeleton message first, then replace the pure builder with real logic later
Do not use for:
- High-frequency fanout (Cron trigger is limited to 1-minute granularity)
- Bidirectional Discord interactions (use Discord Bot with Interactions, not webhooks)
- Anything requiring Cron retry semantics (Workers Cron is fire-once-per-schedule; implement retries on top yourself)
The architecture: pure → boundary
scheduled(event, env)
└─ runScheduled(event, env)
├─ buildMessage(now: Date): Payload ← pure function
└─ postToDiscord(url, payload): Promise<void> ← boundary, never throws
Two rules:
- The builder is pure. No I/O, no
env, no clock reads (takenowas a parameter). This makes it trivially unit-testable and trivially replaceable (Phase N skeleton message → Phase N+1 domain logic). - The sender never throws. All fetch failures are
console.error'd and swallowed. Reasons:- Cron runs on a schedule. A single failed notification should not prevent the next one.
- There's no retry semantics to leverage anyway: Cloudflare documents no automatic retry for failed cron fires (they're only recorded in Past Events).
controller.noRetry()exists in the API, but no documented platform behavior consumes it (verified 2026-08-08). If you need retries, use Durable Object Alarms or retry in-handler. - Throwing from a scheduled handler doesn't help the user — it just shows up as an opaque exception in the Dashboard.
Core deliverables
worker/time.ts— pure UTC→JST conversion (or your timezone of choice). Environment-TZ-independent usinggetUTC*()methodsworker/discord.ts—buildSkeletonMessage(now)(pure) +postToDiscord(url, payload)(boundary, throw-less)worker/cron.ts— wire builder → senderworker/types.ts— addDISCORD_WEBHOOK_URL: stringtoBindings- vitest setup —
vitest.config.ts+ ten tests (6 time + 4 discord) - Production Discord Webhook created and installed via
wrangler secret put DISCORD_WEBHOOK_URL - Development Discord Webhook installed in
.dev.vars(separate channel from prod)
See references/implementation.md for the full source code, ready to paste in.
Dev/prod discipline: webhook naming
Always create two webhooks in separate Discord channels:
<project-name> (dev)→ channel#<project>-dev<project-name> (prod)→ channel#<project>(or wherever prod lives)
Why the naming matters: webhook values (https://discord.com/api/webhooks/<id>/<token>) are not visible after installation (wrangler secret list shows names only). If you accidentally put the dev URL into the prod secret (or vice versa), the only way to detect it is to look at where messages actually arrive.
If a message posted by "routine-tasks (dev)" shows up in the prod channel, you've cross-contaminated. Naming the webhooks identifiably makes this detectable in 30 seconds.
Full operational workflow in references/operations.md.
The vitest testing pattern
For the pure builder: standard table-driven tests with known UTC inputs and expected JST output strings.
For the boundary sender: vi.spyOn(globalThis, "fetch") to mock fetch, then assert:
- Success path: fetch is called with the right URL and method
- Non-2xx: does NOT throw, logs to
console.error - Fetch rejection: does NOT throw, logs to
console.error
Environment must be "node" (not "jsdom"), and the vitest.config.ts must be separate from vite.config.ts — loading the @cloudflare/vite-plugin into the test runner causes unhelpful dev-server side-effects.
Full test code in references/testing.md.
Production setup flow
High-level; detailed commands in references/operations.md:
- User: Create two Discord webhooks with the dev/prod naming discipline above
- User:
wrangler secret put DISCORD_WEBHOOK_URLand paste the prod URL - User: Copy
.dev.vars.example→.dev.vars, paste the dev URL - Agent: Copy
time.ts/discord.ts/ vitest files from references, wirecron.ts - Agent:
pnpm install→pnpm check→pnpm test(10/10 green) - Agent: Commit → push → user merges → auto-deploy
- Validate prod:
wrangler tailshows[cron] fired at ...at scheduled time, Discord prod channel receives[<project>] cron fired at <JST> (skeleton)message
Local Cron testing (/cdn-cgi/handler/scheduled)
Current toolchains expose a local test endpoint for the scheduled handler — both wrangler dev (default :8787) and @cloudflare/vite-plugin@1.x (default :5173):
curl "http://localhost:5173/cdn-cgi/handler/scheduled?cron=15+*+*+*+*"
The cron query param should match an expression in triggers.crons. In wrangler dev you can also press the s hotkey to fire the handler interactively. (Source: Workers Cron Triggers docs, verified 2026-08-07.)
Legacy caveat (@cloudflare/vite-plugin@0.1.x / wrangler 3 baseline): 0.1.x implements no local Cron endpoint at all — the old /__scheduled path falls through to the SPA HTML. On that baseline, either bump to vite-plugin@^1 + wrangler@^4 simultaneously (major version jump, treat as its own task) or skip local Cron testing and rely on vitest mocks + prod verification (wrangler tail at the next scheduled fire). Full tradeoffs in references/operations.md.
Diagnosing "prod Discord isn't receiving"
When the cron fires but messages don't land, work the symptom matrix in references/operations.md:
| Local dev ch | Prod ch | Likely cause |
|---|---|---|
| ✓ arrives | ✗ nothing | Prod secret wrong, or deploy not reflected yet, or Cron not firing |
| ✗ nothing | ✓ arrives | .dev.vars missing, or no local scheduled endpoint on your baseline (see Local Cron testing) |
| ✗ nothing | ✗ nothing | Code-level issue (cron.ts / discord.ts implementation) OR webhook URL itself invalid |
| Dev msg in prod ch | — | .dev.vars has prod URL (swap fix + rotate) |
| Prod msg in dev ch | — | Prod secret has dev URL (swap fix + rotate) |
Detailed diagnostic flow in references/operations.md — including Dashboard Observability setup, wrangler deployments list-based reflection check, and webhook rotation procedure with reflection-verification.
Progressive disclosure: when to replace skeleton
When real domain logic arrives (e.g., "notify about unfinished tasks from D1"):
- Keep the signatures of
buildMessageandpostToDiscordstable. This is the whole point of the boundary: you don't rewire Cron to swap the logic - Replace
buildSkeletonMessage(now)withbuildNotificationMessage(tasks: Task[], now: Date)— still pure - Update
discord.test.tsto cover the new builder inputs (table-driven: (tasks, now) → expected payload) - Update
cron.tsto query D1 first, then feed results to the builder
The boundary (postToDiscord) stays exactly the same. The only thing that changes is what's upstream of it.
Scope boundary
This skill does NOT cover:
- The underlying Workers skeleton (
wrangler.jsonc, GH Actions, D1) — seecloudflare-workers-deploy-skeleton - Replacing skeleton logic with real domain rules — that's a project-phase decision; the boundary is designed to make it easy
- Discord embeds, mentions, reaction-based interactions — webhooks support them (add
embeds/componentsfields in the payload) but skeleton usescontentonly - Non-Discord recipients — the architecture ports to Slack / Teams / generic HTTP recipients; only the payload shape changes
neverthrow/ Result-type error handling — deferred; skeleton uses try/catch withconsole.errorfor simplicity
References
- implementation.md — full source of
time.ts,discord.ts,cron.ts,types.tsaddition - testing.md —
vitest.config.ts,time.test.ts,discord.test.tswith fetch-mock pattern - operations.md — Discord webhook creation steps, dev/prod secret workflow, symptom matrix, diagnostic flow, rotation procedure