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:
- Builder reads
spec.schedulesfrom yourcatalog-info.yaml - Each schedule is registered as a binding in Koko (the service registry)
iec-cronpolls Koko every 30 seconds and syncs its internal job list- When a cron expression fires,
iec-cronsends aPOSTto your service's callback endpoint via the internal Kubernetes DNS - 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 } │
│ │<──────────────────────│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 orgsMultiple 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: 60000Schedule fields
| Field | Required | Default | Description |
|---|---|---|---|
name | Yes | — | Kebab-case identifier, unique within your service |
cron | Yes | — | Standard 5-field cron expression |
endpoint | Yes | — | Path on your service that iec-cron will POST to |
timezone | No | UTC | IANA timezone for cron evaluation (e.g. America/Denver) |
timeoutMs | No | 30000 | Max time in ms iec-cron will wait for your handler to respond |
description | No | — | Human-readable description shown in the platform dashboard |
/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"
}| Field | Type | Description |
|---|---|---|
scheduleName | string | The name from your catalog-info.yaml |
namespace | string | The K8s namespace (e.g. my-org-prod) |
firedAt | string (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/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/cronTypes 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
| Event | Gas cost |
|---|---|
| Callback succeeded (2xx) | 2 tokens |
| Callback failed or timed out | 1 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/DenverUse 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-reportIs 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
- catalog-info.yaml Reference — full field reference including schedules section
- Gas & Wallets — understanding token costs for scheduled invocations
- Databases — connecting to MongoDB/Redis inside your cron handler