Scheduled Jobs

Declare cron schedules in catalog-info.yaml and the platform fires HTTP callbacks to your service on time — no node-cron, no clock drift, no duplicate fires across replicas.

How it works

The Tawa platform includes iec-cron, a dedicated scheduler service that owns the clock for all registered jobs across the cluster. Here's what happens on every deploy:

  1. Builder reads spec.schedules from your catalog-info.yaml
  2. Each schedule is registered as a binding in Koko (the service registry)
  3. iec-cron polls Koko every 30 seconds and syncs its internal job list
  4. When a cron expression fires, iec-cron sends a POST to your service's callback endpoint via the internal Kubernetes DNS
  5. Your handler runs the job and returns 200. Gas is debited from your wallet.
Koko                iec-cron              Your Service
  │                     │                       │
  │ GET schedule        │                       │
  │ bindings            │                       │
  │<────────────────────│                       │
  │─────────────────────>                       │
  │                     │  ⏰ cron fires         │
  │                     │  POST /internal/      │
  │                     │  cron/my-job          │
  │                     │──────────────────────>│
  │                     │  { ok: true }         │
  │                     │<──────────────────────│
Why not node-cron? In-process schedulers run inside your pod. When the pod restarts (deploy, crash, OOM kill), the scheduler resets. With iec-cron, the clock lives outside your service — pod restarts are invisible to it.

Declaring a schedule

Add a schedules: block under spec in your catalog-info.yaml:

spec:
  schedules:
    - name: daily-report         # kebab-case, unique within service
      cron: "0 8 * * *"          # standard 5-field cron expression
      endpoint: /internal/cron/daily-report
      timezone: America/Denver   # IANA timezone (default: UTC)
      timeoutMs: 30000           # callback timeout in ms (default: 30000)
      description: Send daily summary to all active orgs

Multiple schedules

spec:
  schedules:
    - name: campaign-sync
      cron: "*/15 * * * *"
      endpoint: /internal/cron/campaign-sync
      timezone: America/Denver
      timeoutMs: 60000
    - name: loan-sync
      cron: "0 * * * *"
      endpoint: /internal/cron/loan-sync
      timezone: America/Denver
      timeoutMs: 60000

Schedule fields

FieldRequiredDefaultDescription
nameYesKebab-case identifier, unique within your service
cronYesStandard 5-field cron expression
endpointYesPath on your service that iec-cron will POST to
timezoneNoUTCIANA timezone for cron evaluation (e.g. America/Denver)
timeoutMsNo30000Max time in ms iec-cron will wait for your handler to respond
descriptionNoHuman-readable description shown in the platform dashboard
Endpoint path convention: Use /internal/cron/{name} as the path. The /internal/ prefix signals that this route is for platform callbacks, not public API traffic. Do not add auth middleware to these routes — iec-cron calls them over the internal cluster network, not through Janus.

Callback contract

When the schedule fires, iec-cron sends a POST to your endpoint with this JSON body:

POST /internal/cron/daily-report
Content-Type: application/json

{
  "scheduleName": "daily-report",
  "namespace":    "my-org-prod",
  "firedAt":      "2026-02-24T08:00:00.000Z"
}
FieldTypeDescription
scheduleNamestringThe name from your catalog-info.yaml
namespacestringThe K8s namespace (e.g. my-org-prod)
firedAtstring (ISO 8601)The UTC timestamp when the cron expression fired

Return any 2xx status to confirm success. Non-2xx responses and timeouts are both logged as failures in the platform dashboard. Gas is debited either way (1 token for failures, 2 for success).

Implementing the handler

Next.js (App Router)

// app/internal/cron/daily-report/route.ts
import { NextResponse } from 'next/server'
import { connectDB } from '@/lib/db'
import { sendDailyReport } from '@/lib/services/reporting'

export async function POST() {
  try {
    await connectDB()
    const result = await sendDailyReport()
    return NextResponse.json({ ok: true, sent: result.count })
  } catch (err) {
    console.error('daily-report cron error:', err)
    return NextResponse.json({ error: 'Internal error' }, { status: 500 })
  }
}
App Router path matters. A file at app/internal/cron/daily-report/route.ts maps to /internal/cron/daily-report. Do not put it under app/api/ unless you want the /api/ prefix in the URL.

Express / Hono

// src/routes/internal.ts
router.post('/internal/cron/daily-report', async (req, res) => {
  try {
    const result = await sendDailyReport()
    res.json({ ok: true, sent: result.count })
  } catch (err) {
    console.error('daily-report cron error:', err)
    res.status(500).json({ error: 'Internal error' })
  }
})

Using @insureco/cron

The @insureco/cron package provides TypeScript types for the callback body and typed handler wrappers so you can't accidentally miss the contract.

npm install @insureco/cron

Types only

import type { CronCallbackBody } from '@insureco/cron'

export async function POST(request: Request) {
  const body: CronCallbackBody = await request.json()
  console.log(body.scheduleName, body.firedAt)
  // ...
}

Next.js handler wrapper

import { nextjsCronHandler } from '@insureco/cron'
import { sendDailyReport } from '@/lib/services/reporting'

export const POST = nextjsCronHandler(async ({ scheduleName, firedAt }) => {
  const result = await sendDailyReport()
  return { sent: result.count }
})

Express handler wrapper

import { expressCronHandler } from '@insureco/cron'
import { sendDailyReport } from '../services/reporting'

router.post('/internal/cron/daily-report',
  expressCronHandler(async ({ scheduleName }) => {
    const result = await sendDailyReport()
    return { sent: result.count }
  })
)

Both wrappers automatically catch errors and return a structured JSON response. The callback receives the parsed CronCallbackBody — no manual request.json() needed.

Gas costs

EventGas cost
Callback succeeded (2xx)2 tokens
Callback failed or timed out1 token

Gas is debited from wallet-{orgSlug}. At 2 tokens per fire, a job running every 15 minutes costs ~5,760 tokens/month ($57.60). Hourly jobs cost ~1,440 tokens/month ($14.40).

Timezones

Cron expressions are always evaluated in the timezone specified by the timezone field. If omitted, UTC is used.

# Fires at 8 AM Mountain Time every weekday
- name: morning-report
  cron: "0 8 * * 1-5"
  endpoint: /internal/cron/morning-report
  timezone: America/Denver

Use IANA timezone names (e.g. America/New_York, America/Chicago, Europe/London). DST transitions are handled automatically.

FAQ

What if my pod is down when the schedule fires?

iec-cron will receive a connection error, log it as a failed invocation, and debit 1 token. It does not retry automatically — the next fire will happen on the next scheduled interval.

Can I change the cron expression without losing history?

Yes. Edit the cron field in catalog-info.yaml and redeploy. iec-cron detects the changed binding and restarts the job with the new expression. History from the old expression is preserved in the platform dashboard.

Can I remove a schedule?

Yes. Remove the entry from spec.schedules and redeploy. iec-cron will stop the job within 30 seconds (next Koko sync cycle).

Do schedules run in sandbox and prod separately?

Yes. Each deploy environment (sandbox, prod, uat) gets its own set of schedule bindings in Koko. iec-cron fires them independently. If you don't want a schedule in sandbox, you can use environment-specific catalog files or just accept the lower traffic from sandbox runs.

Can I trigger a schedule manually to test it?

Yes — just POST to the endpoint directly:

curl -X POST https://my-svc.tawa.insureco.io/internal/cron/daily-report

Is there a way to see upcoming run times?

The iec-cron admin API exposes next run times for all active schedules. This will be surfaced in the platform console in a future release.

Related