Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

Scheduled Jobs

Reactor Jobs runs background work on a schedule or in response to events. This guide covers cron jobs, webhook-triggered runs, job handlers, and monitoring — integrated with Data and Functions.

Prerequisites: Running Reactor server with [jobs] configured, deployed function or script handler.


Reactor.toml
[jobs]
worker_count = 4
scheduler_interval_ms = 1000
default_timeout_ms = 600000 # 10 minutes
webhook_secret = "{{ env REACTOR_JOBS_WEBHOOK_SECRET }}"
max_org_concurrent_runs = 50

Verify the scheduler is running:

Terminal window
curl http://localhost:8000/health | jq '.capabilities.jobs'
# "ok"

Job handlers are functions deployed to the Functions capability:

functions/daily-report/index.ts
import { ReactorClient } from "@reactor/client";
interface JobPayload {
org_id: string;
report_type: "daily" | "weekly";
}
export default async function handler(req: Request): Promise<Response> {
const payload: JobPayload = await req.json();
const reactor = ReactorClient.service(); // service role — bypasses RLS
// Aggregate data
const yesterday = new Date(Date.now() - 86400_000).toISOString();
const { data: orders } = await reactor.data
.from("orders")
.select("total, status")
.eq("org_id", payload.org_id)
.gte("created_at", yesterday);
const summary = {
total_orders: orders?.length ?? 0,
revenue: orders?.reduce((sum, o) => sum + o.total, 0) ?? 0,
completed: orders?.filter(o => o.status === "completed").length ?? 0,
};
// Store report
await reactor.data.from("reports").insert({
org_id: payload.org_id,
type: payload.report_type,
data: summary,
generated_at: new Date().toISOString(),
});
console.log(`Report generated for org ${payload.org_id}:`, summary);
return Response.json({ ok: true, summary });
}

Deploy:

Terminal window
reactor functions deploy daily-report

Jobs are registered via manifest in your deploy bundle or the Jobs API:

jobs/manifest.json
{
"jobs": [
{
"id": "daily-sales-report",
"name": "Daily Sales Report",
"handler": "daily-report",
"timeout_ms": 300000,
"retries": 3,
"triggers": [
{
"type": "cron",
"schedule": "0 6 * * *",
"timezone": "America/New_York",
"payload": {
"org_id": "00000000-0000-0000-0000-000000000001",
"report_type": "daily"
}
}
]
},
{
"id": "cleanup-expired-sessions",
"name": "Cleanup Expired Sessions",
"handler": "cleanup-sessions",
"timeout_ms": 60000,
"retries": 1,
"triggers": [
{
"type": "cron",
"schedule": "*/15 * * * *",
"payload": {}
}
]
}
]
}

Deploy the manifest:

Terminal window
reactor deploy # includes jobs/manifest.json in bundle

Or register via API:

Terminal window
curl -X POST \
-H "Authorization: Bearer $USER_JWT" \
-H "Content-Type: application/json" \
http://localhost:8000/jobs/v1/jobs \
-d @jobs/manifest.json

Reactor uses standard 5-field cron syntax:

┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, Sun=0)
│ │ │ │ │
* * * * *
ScheduleExpression
Every minute* * * * *
Every 15 minutes*/15 * * * *
Every hour0 * * * *
Daily at 6 AM0 6 * * *
Weekdays at 9 AM0 9 * * 1-5
First of month0 0 1 * *

Trigger jobs from external systems (Stripe, GitHub, custom apps):

// Register webhook trigger in manifest
{
"id": "stripe-payment-processor",
"handler": "process-payment",
"triggers": [
{
"type": "webhook",
"path": "/hooks/stripe",
"method": "POST",
"verify": {
"type": "hmac",
"header": "stripe-signature",
"secret": "vault:jobs/stripe_webhook_secret"
}
}
]
}

External caller:

Terminal window
curl -X POST \
-H "Content-Type: application/json" \
-H "stripe-signature: t=123,v1=abc..." \
https://api.myapp.com/jobs/v1/hooks/stripe \
-d '{"type":"payment_intent.succeeded","data":{...}}'

Manual trigger from your app:

await reactor.jobs.trigger("daily-sales-report", {
org_id: org.id,
report_type: "weekly",
});

A cleanup job that archives old files and deletes database records:

functions/archive-old-uploads/index.ts
export default async function handler(req: Request) {
const reactor = ReactorClient.service();
const cutoff = new Date(Date.now() - 90 * 86400_000).toISOString();
const { data: files } = await reactor.data
.from("uploads")
.select("id, storage_path")
.lt("created_at", cutoff)
.eq("archived", false)
.limit(100);
for (const file of files ?? []) {
// Move to archive bucket
await reactor.storage
.from("uploads")
.move(file.storage_path, `archive/${file.storage_path}`);
await reactor.data
.from("uploads")
.update({ archived: true, archived_at: new Date().toISOString() })
.eq("id", file.id);
}
return Response.json({ archived: files?.length ?? 0 });
}

Terminal window
curl -H "Authorization: Bearer $JWT" \
"http://localhost:8000/jobs/v1/runs?limit=10" | jq
[
{
"id": "run_01HZ...",
"job_id": "daily-sales-report",
"status": "completed",
"started_at": "2026-05-29T06:00:01Z",
"completed_at": "2026-05-29T06:00:04Z",
"duration_ms": 3200
}
]
# Job failure rate
rate(reactor_jobs_runs_total{status="failed"}[1h])
# Queue depth
reactor_jobs_queue_depth
# Run duration P95
histogram_quantile(0.95, rate(reactor_jobs_run_duration_seconds_bucket[1h]))
Terminal window
reactor logs --capability jobs --follow

{
"id": "sync-external-api",
"handler": "sync-api",
"timeout_ms": 120000,
"retries": 5,
"retry_backoff": {
"type": "exponential",
"initial_ms": 1000,
"max_ms": 60000,
"multiplier": 2
},
"triggers": [{ "type": "cron", "schedule": "0 */4 * * *" }]
}

In the handler, return structured errors for retryable vs fatal failures:

try {
await syncData();
return Response.json({ ok: true });
} catch (err) {
if (err.code === "RATE_LIMITED") {
// Will retry with backoff
return new Response(JSON.stringify({ error: err.message }), { status: 503 });
}
// Fatal — won't retry
return new Response(JSON.stringify({ error: err.message }), { status: 422 });
}

Trigger typeUse case
CronReports, cleanup, periodic sync
WebhookExternal events (payments, CI, webhooks)
ManualOn-demand via SDK or API
Data eventReact to inserts/updates (via function trigger)