Scheduled Jobs
Configure scheduled jobs using Upstash QStash for automated server-side workflows.
LaunchSaaS includes a flexible scheduled jobs system powered by Upstash QStash, enabling you to run automated tasks on a schedule without managing your own cron infrastructure.
Updated: 2026-03-22 - Removed the built-in content notification example and clarified custom job registration.
Overview
The scheduled jobs feature provides:
- Provider Abstraction: Easy to switch between cron providers or disable entirely
- Webhook-based Execution: Jobs are triggered via HTTP webhooks, perfect for serverless environments
- Signature Verification: Secure webhook endpoints with automatic signature validation
- Custom Job Registration: Register any
CronJobimplementation with your provider
Architecture
┌─────────────────┐ triggers ┌────────────────────────────────┐
│ Upstash QStash │────────────────►│ POST /api/cron/{provider}/{id} │
│ (Scheduler) │ └──────────────┬─────────────────┘
└─────────────────┘ │
│ 1. Check capabilities.cron
│ 2. Verify webhook signature
│ 3. Find job in cronProvider.jobs
▼
┌─────────────────┐
│ Job Handler │
│ (CronJob) │
└─────────────────┘Two sides of the system:
CronProvider(capabilities.cron) — external-facing: verifies signatures from QStash, optionally manages schedules via API; also owns the registered job list viacronProvider.jobs
Configuration
1. Get QStash Credentials
- Go to the Upstash Console
- Navigate to QStash section
- Copy your credentials:
QSTASH_TOKENQSTASH_CURRENT_SIGNING_KEYQSTASH_NEXT_SIGNING_KEY
2. Set Environment Variables
Add the following to your .env file:
# Cron Provider - Upstash QStash
QSTASH_TOKEN=your_qstash_token
QSTASH_CURRENT_SIGNING_KEY=your_current_signing_key
QSTASH_NEXT_SIGNING_KEY=your_next_signing_key3. Enable the Cron Provider in capabilities
Update src/capabilities.ts:
import { QStashCronProvider } from "@launchsaas/cron-qstash";
export const capabilities = {
// ...
cron: QStashCronProvider.create([]),
};When cron is null, the webhook endpoint returns 503 and no external requests are made.
4. Set up the webhook route
Create the API route file that receives QStash webhooks:
// src/app/api/cron/[provider]/[job]/route.ts
import { toHandler } from "@launchsaas/cron";
import { capabilities } from "@/capabilities";
export const { POST } = toHandler(capabilities.cron);The [provider] segment is validated against capabilities.cron.name and [job] is matched against the registered job IDs.
Creating a Schedule in QStash
- Go to the QStash Console
- Click Schedules → Create Schedule
- Configure the schedule:
- Destination URL:
https://your-domain.com/api/cron/qstash/my-custom-job - Schedule: Use cron expression (e.g.,
0 9 * * *for daily at 9 AM UTC) - Method:
POST
- Destination URL:
Common Cron Expressions
| Expression | Description |
|---|---|
0 9 * * * | Daily at 9:00 AM UTC |
0 */6 * * * | Every 6 hours |
0 9 * * 1 | Every Monday at 9:00 AM UTC |
0 0 1 * * | First day of each month |
Creating Custom Jobs
1. Create a Job Handler
Create a new file in src/lib/features/cron/jobs/:
// src/lib/features/cron/jobs/my-custom-job.ts
import "server-only";
import type { CronJob } from "@launchsaas/cron";
import { NextResponse } from "next/server";
export class MyCustomJob implements CronJob {
readonly id = "my-custom-job";
readonly name = "My Custom Job";
readonly description = "Does something awesome on a schedule";
readonly schedule = "0 */12 * * *"; // Informational, actual schedule set in QStash
async handle(_request: Request): Promise<Response> {
// Your job logic here
return NextResponse.json({ success: true, message: "Job completed" });
}
}2. Register the Job
Pass it to the provider in src/capabilities.ts:
import { QStashCronProvider } from "@launchsaas/cron-qstash";
import { MyCustomJob } from "@/lib/features/cron/jobs/my-custom-job";
cron: QStashCronProvider.create([
new MyCustomJob(),
]),3. Create Schedule in QStash
Add a new schedule in the QStash dashboard pointing to:
https://your-domain.com/api/cron/qstash/my-custom-jobThe {job} URL segment must match your job's id property exactly.
How CronProvider Works
When QStash fires a webhook:
POST /api/cron/qstash/my-custom-jobarrives- Route checks
capabilities.cron— ifnull, returns 503 - Route validates the provider name in the URL against
capabilities.cron.name - Route calls
capabilities.cron.verifySignature(request)— QStash signature is verified - Route finds the job via
capabilities.cron.jobs.find(j => j.id === jobId) - Route calls
job.handle(request)— executes your logic
// Simplified route handler
const cronProvider = capabilities.cron; // CronProvider (QStash)
const job = cronProvider.jobs.find((j) => j.id === jobId); // Your job class
await cronProvider.verifySignature(request); // Verify it's really from QStash
await job.handle(request); // Run your codeNewsletter Integration
Custom jobs can integrate with the newsletter system to send broadcast emails:
const newsletter = capabilities.newsletter;
if (newsletter) {
await newsletter.broadcast({
subject: "Product update from LaunchSaaS",
react: MyEmailTemplate({}),
});
}Monitoring
QStash Dashboard
Monitor your scheduled jobs in the QStash Console:
- Schedules: View and manage active schedules
- Logs: See execution history and errors
- Messages: Track individual job invocations
Response Codes
| Code | Meaning |
|---|---|
| 200 | Job executed successfully |
| 401 | Invalid webhook signature |
| 404 | Job or provider not found |
| 500 | Internal server error |
| 503 | Cron is not configured |
Security
Signature Verification
All incoming webhook requests are verified using QStash's signature mechanism:
- QStash signs each request with your signing key
- The webhook endpoint verifies the signature before executing the job
- Invalid signatures are rejected with 401 Unauthorized
Never expose your QSTASH_TOKEN or signing keys in client-side code. These
are server-only credentials.
Best Practices
- Use HTTPS: Always use HTTPS for webhook endpoints in production
- Rotate Keys: Periodically rotate signing keys using QStash key rotation feature
- Monitor Failures: Set up alerts for job failures in the QStash dashboard
- Idempotent Jobs: Design jobs to be idempotent (safe to run multiple times)
Troubleshooting
Common Issues
Job not triggering:
- Verify the schedule is active in QStash dashboard
- Check the destination URL is correct and publicly accessible
- Ensure environment variables are set in production
Signature verification failing:
- Confirm
QSTASH_CURRENT_SIGNING_KEYandQSTASH_NEXT_SIGNING_KEYmatch QStash console - Check for key rotation - update both keys if recently rotated
Job not found (404):
- Ensure the job ID in the URL matches the
idproperty of the job class exactly - Confirm the job instance is passed to
QStashCronProvider.create([...])insrc/capabilities.ts
Emails not sending:
- Verify the
newslettercapability is configured (non-null) insrc/capabilities.ts - Check Resend API key and audience ID are set
- Look for errors in the job response logs
Content not detected as new:
- With cache: Check if content was already processed (cached)
- Without cache: Content must be published TODAY to be detected