Scheduled Jobs on Tawa

The Short Version

Declare schedules in catalog-info.yaml. iec-cron POSTs to your endpoint on schedule. Return 200 immediately — do slow work asynchronously.

spec:
  schedules:
    - name: nightly-sync
      cron: "0 2 * * *"
      endpoint: /internal/cron/nightly-sync
      timezone: America/Denver
      timeoutMs: 300000

Handler Pattern

// Return 200 immediately — iec-cron must not wait for slow work
app.post('/internal/cron/nightly-sync', async (req, res) => {
  res.json({ success: true })

  // Do work asynchronously after responding
  doSlowWork().catch((err) => logger.error({ err }, 'Nightly sync failed'))
})

// For fast work (< timeoutMs), you can do it inline:
app.post('/internal/cron/hourly-check', async (req, res) => {
  await runQuickCheck()
  res.json({ success: true })
})

How iec-cron Calls Your Service

iec-cron POSTs the callback through Janus at {JANUS_URL}/i/{serviceName}{endpoint}, where serviceName is your namespace with the environment suffix stripped (e.g. my-svc-prod/i/my-svc/internal/cron/...). The service JWT is passed as Authorization: Bearer.

POST {JANUS_URL}/i/{serviceName}{endpoint}
Authorization: Bearer {service-jwt}
X-Schedule-Name: nightly-sync
X-Cron-Expression: 0 2 * * *
X-Fired-At: 2026-01-15T07:00:00.000Z

Cron Syntax

┌──────── minute (0-59)
│ ┌────── hour (0-23)
│ │ ┌──── day of month (1-31)
│ │ │ ┌── month (1-12)
│ │ │ │ ┌ day of week (0-7, both 0 and 7 = Sunday)
│ │ │ │ │

"*/15 * * * *"      every 15 minutes
"0 * * * *"         top of every hour
"0 0 * * *"         daily at midnight UTC
"0 9 * * 1-5"       weekdays at 9am
"0 2 1 * *"         1st of each month at 2am

Only 5-part cron expressions. 6-part (with seconds) is NOT supported.

Schedule Fields

FieldRequiredDefaultDescription
nameYesUnique name in your namespace
cronYes5-part cron expression
endpointYesPath in your service
timezoneNoUTCIANA timezone string
timeoutMsNo30000HTTP callback timeout
descriptionNoHuman description

Gas

EnvironmentCost per execution
Production5 tokens
Sandbox0 tokens (free)

Gas is charged upfront when the schedule fires, before your callback is invoked. Sandbox suppresses fires entirely and charges no gas.

What NOT to Do

# ❌ WRONG: cron endpoints listed in routes (they must be internal-only)
spec:
  routes:
    - path: /internal/cron/nightly-sync   # now exposed through Janus

# ✅ CORRECT: cron endpoints only in schedules
spec:
  schedules:
    - name: nightly-sync
      endpoint: /internal/cron/nightly-sync

# ❌ WRONG: /api/ prefix on cron endpoints
endpoint: /api/cron/nightly-sync   # suggests it's a public API route

# ✅ CORRECT
endpoint: /internal/cron/nightly-sync

# ❌ WRONG: 6-part cron (with seconds)
cron: "0 0 2 * * *"   # second field not supported

# ✅ CORRECT
cron: "0 2 * * *"
// ❌ WRONG: blocking the response with slow work
app.post('/internal/cron/nightly-sync', async (req, res) => {
  await doSlowWork()   // iec-cron waits, times out, marks failure
  res.json({ ok: true })
})

// ✅ CORRECT: respond immediately
app.post('/internal/cron/nightly-sync', async (req, res) => {
  res.json({ ok: true })
  doSlowWork().catch(logger.error)
})

Key Facts

  • iec-cron polls Koko every 30 seconds — new schedules go live within 30 seconds of deploy
  • Schedule names must be unique within your namespace
  • Non-2xx response or timeout = recorded as failure in run history
  • Run history viewable in tawa-web: Console → Infrastructure → Schedules
  • Septor events are written automatically for every cron execution (platform handles this)

Last updated: July 15, 2026