Oolvay ships with two cron jobs, webhook reconciliation and notification cleanup, both running on a fixed schedule through Vercel Cron. This is a different mechanism from Inngest, which reacts to events instead of running on a timetable. Nothing here is event-driven, and nothing in Inngest runs on a timetable. They are unrelated systems that happen to both execute outside a normal request.
Webhook reconciliation retries payment webhook events that failed to process the first time. Notification cleanup deletes read notifications past their retention window.
There is no toggle to disable either job the way Ably, PostHog, or Inngest can be turned off. Both run as part of the application’s own upkeep, not as optional integrations. This guide covers how each job works, the environment variable they share, and how to add a new one of your own.
vercel.json defines what gets called and when, using a standard cron schedule expression.
{
"crons": [
{
"path": "/api/payments/cron/reconcile",
"schedule":
0 / 2,000 characters
Vercel issues a GET request to each path at its scheduled time. That is the entirety of what Vercel Cron does. It does not retry on failure, does not queue, and does not know anything about what the route handler actually does. Everything beyond “call this URL at this time” is the route handler’s own responsibility.
Both routes begin by comparing the authorization header against CRON_SECRET. Here is the reconciliation route’s check, the cleanup route’s is identical.
const authHeader = req.headers.get("authorization")
if (authHeader !== `Bearer ${env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}This exists because both routes are publicly reachable URLs. Without this check, anyone who discovers either path could trigger a reconciliation pass or a cleanup pass on demand. Vercel Cron itself sends this header automatically using the value of CRON_SECRET configured in your project’s environment variables, so the check passes for legitimate scheduled calls without any extra setup beyond having the variable set.
If CRON_SECRET is unset, this route returns 401 on every invocation,
including the real scheduled one from Vercel. A cron job that silently never
runs because of a missing secret looks identical, from the outside, to one
that simply has not fired yet. Check for 401 responses in your logs first if
reconciliation appears to not be running at all.
The reconciliation route queries webhook_events for rows marked failed within a 24 hour window.
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000) // 24 hours ago
const failedEvents = await db
.select()
.from(webhookEvents)
.where(
and(
eq(webhookEvents.status, "failed"),
gt(webhookEvents.receivedAt, cutoff)
)
)For each row found, the route resolves the correct adapter through getProviderByName(event.provider), since a single reconciliation run can contain rows from more than one provider, not just whichever provider your PAYMENT_PROVIDER env var currently points at. getProviderByName() only returns an adapter for provider names it explicitly handles, check lib/payments/get-provider.ts for the current list. A row whose provider value is not handled there resolves to undefined, and the subsequent call into that adapter throws, which the catch block below reports as a generic error rather than anything provider-specific. It then re-normalizes the raw payload through that provider’s own normalizeWebhookEvent(), and re-dispatches it through the same handler logic a live webhook would have used.
for (const event of failedEvents) {
const provider = await getProviderByName(event.provider)
try {
const normalizedEvent = provider.normalizeWebhookEvent(
event.rawPayload as Record<string, unknown>
)
if (!normalizedEvent || normalizedEvent.type === "unknown") {
await db
.update(webhookEvents)
.set({ status: "ignored", processedAt: new Date() })
.where(eq(webhookEvents.id, event.id))
results.skipped++
continue
}
await dispatchNormalizedEvent(normalizedEvent)
await db
.update(webhookEvents)
.set({ status: "processed", processedAt: new Date() })
.where(eq(webhookEvents.id, event.id))
results.processed++
} catch (error) {
// logged, reported to Sentry, error column updated, loop continues
results.failed++
}
}Each event is wrapped in its own try/catch, so one event failing again does not stop the rest of the batch from being attempted. On a repeat failure, the row’s error column is updated with the latest failure message, but its status is left untouched, so a row that was already failed simply stays failed. The catch block also logs through requestLogger and reports to Sentry, scoped with the provider name, the webhook ID, and the request ID, so a repeat failure is visible in your error tracking even though the HTTP response for the whole cron run still comes back successfully. The row remains in the table for the next scheduled run to try again, rather than being dropped after a single retry attempt.
The route tracks processed, failed, and skipped counts across the whole run and returns them in the response body, so a glance at the cron’s own logged response tells you the shape of that run without needing to query webhook_events directly.
This job is not only documentation glue, your own logging infrastructure
depends on it existing. lib/logger/transports/queued.ts, the queue sitting
in front of any external log transport, explicitly does not re-queue entries
that fail to send, with a comment noting that the reconciliation cron handles
missed events at the application level instead. Removing or disabling this job
has a wider blast radius than just payments.
The cleanup route deletes rows from userNotification that are both read and older than siteConfig.notifications.retentionDays.
const cutoff = new Date(
Date.now() - siteConfig.notifications.retentionDays * 24 * 60 * 60 * 1000
)
const deleted = await db
.delete(userNotification)
.where(
and(eq(userNotification.read, true), lt(userNotification.readAt, cutoff))
)
.returning({
id: userNotification.id,
})Only read notifications are deleted. An unread notification stays in the table indefinitely, regardless of age, since readAt is what the cutoff compares against, and an unread row has no readAt value to compare. This means a notification a user never opens is never cleaned up by this job. Unlike reconciliation, this route does not retry per row, a failure here is logged once, reported to Sentry, and the route returns a 500, with the next scheduled run simply trying the full delete again.
} catch (error) {
// logged, reported to Sentry with the cleanup tag and fingerprint
return NextResponse.json(
{ success: false, error: "Failed to clean notifications." },
{ status: 500 }
)
}Only one environment variable is needed for cron, CRON_SECRET. It is not generated or supplied by Vercel, you generate it yourself, the same as BETTER_AUTH_SECRET or any other local secret in this codebase.
openssl rand -base64 32Assign the result to CRON_SECRET in .env.local for local development, and to the same variable name in your hosting platform’s environment variables for production.
CRON_SECRET=Once CRON_SECRET exists in your project’s environment variables, Vercel reads that value back out and attaches it automatically as a bearer token on its own scheduled calls. Vercel is consuming a value you set, not generating one for you.
If you call either route manually, for testing, you need to send this header yourself. Vercel only attaches it automatically on its own scheduled invocations.
curl -H "Authorization: Bearer your-cron-secret" https://acme.com/api/payments/cron/reconcilecurl -H "Authorization: Bearer your-cron-secret" https://acme.com/api/notifications/cron/cleanupA cron expression is five fields, in this exact order, minute, hour, day of month, month, day of week. A * in any field means "every value," and a specific number means "only this value."
+------------- minute (0-59)
| +------------- hour (0-23)
| | +------------- day of month (1-31)
| | | +------------- month (1-12)
| | | | +------------- day of week (0-6)
| | | | |
* * * * *Reconciliation’s schedule, 0 0 * * *, sets minute 0 and hour 0, with every other field left as a wildcard, in plain terms, midnight, every day. Cleanup’s schedule, 0 3 * * *, follows the same pattern but for hour 3, or 3 AM instead of midnight. That three-hour gap between the two jobs is intentional, as covered below in “Stagger schedules instead of stacking them.”
A few common patterns worth recognizing on sight.
| Expression | Meaning |
|---|---|
0 0 * * * | Once a day, at midnight |
0 */6 * * * | Every 6 hours |
0 0 * * 0 | Once a week, midnight on Sunday |
0 0 1 * * | Once a month, midnight on the 1st |
*/15 * * * * | Every 15 minutes |
For anything more specific than these, or to confirm a schedule before deploying it, use crontab.guru, which translates an expression into plain English and flags invalid ones as you type.
Reconciliation and notification cleanup are the two jobs the starter kit ships with, but the pattern extends cleanly if your application needs others, a periodic report, a data export, anything that should run on a timetable rather than in response to an event.
Vercel’s Hobby plan restricts cron jobs to once per day, and an expression that would run more often fails at deploy time, not at runtime, so you find out immediately rather than discovering it later in production. Hobby also has no precision guarantee, a job scheduled for 0 1 * * * may fire any time between 1:00 am and 1:59 am. The Pro and Enterprise plans both support once-per-minute scheduling with per-minute precision instead.
| Plan | Cron jobs per project | Minimum interval | Scheduling precision |
|---|---|---|---|
| Hobby | 100 | Once per day | Per-hour (±59 min) |
| Pro | 100 | Once per minute | Per-minute |
| Enterprise | 100 | Once per minute | Per-minute |
If you are on Hobby and write a new job with a schedule like */30 * * * *, deployment fails outright with an error telling you Hobby accounts are limited to daily cron jobs. The fix is either a daily schedule, or an upgrade to Pro.
These limits are Vercel’s, not Oolvay’s, and they can change. Check Vercel’s own cron usage and pricing page before relying on the numbers above for a production decision.
Each cron invocation also runs as a Vercel Function, so the same function usage and pricing limits that apply to your other routes apply here too, a cron job is not a separate, unmetered mechanism.
Reconciliation runs at 0 0 * * * and cleanup runs at 0 3 * * *, three hours apart, on purpose. Avoid scheduling a new job at the exact same minute as an existing one unless the two are genuinely independent and cheap, since Vercel queues function invocations the same way any other request would be handled, and two heavy jobs firing at once compete for the same per-project concurrency rather than running in true parallel.
A simple default is to give every new cron job its own hour, or its own few-hour offset, from whatever already runs. This also makes your own logs easier to read, two jobs that fired three hours apart are trivial to tell apart in a log stream, two that fired in the same minute are not.
Write the route handler under a path of your choosing, for example app/api/your-feature/cron/your-job-name/route.ts, starting with the same authorization guard both existing jobs use, and the same logging and Sentry pattern on failure.
export const runtime = "nodejs"
import { NextRequest, NextResponse } from "next/server"
import * as Sentry from "@sentry/nextjs"
import { env } from "@/env"
import { requestLogger } from "@/lib/logger/request-logger"
import { getRequestId } from "@/lib/observability/get-request-id"
export async function GET(req: NextRequest) {
const log = requestLogger(req)
const authHeader = req.headers.get("authorization")
if (authHeader !== `Bearer ${env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
// your job logic here
return NextResponse.json({ success: true })
} catch (error) {
const requestId = await getRequestId(req)
log.error("Your job failed", { error })
Sentry.withScope((scope) => {
scope.setTag("job", "your-job-name")
if (requestId) scope.setTag("request_id", requestId)
Sentry.captureException(error)
})
return NextResponse.json(
{ success: false, error: "Job failed." },
{ status: 500 }
)
}
}Add an entry to vercel.json with the path and schedule.
{
"crons": [
{
"path": "/api/payments/cron/reconcile",
"schedule": "0 0 * * *"
},
{
"path": "/api/notifications/cron/cleanup",
"schedule": "0 3 * * *"
},
{
"path": "/api/your-feature/cron/your-job-name",
"schedule": "0 12 * * *"
Deploy. Vercel reads vercel.json at deploy time, scheduled jobs only take effect once the deployment containing them is live, not immediately when the file is edited locally.
Webhook reconciliation and notification cleanup are both maintenance, not features, but skipping either carries a real cost, not just a technical one.
Removing reconciliation does not break payments outright. It means a webhook that fails its first delivery, say a successful charge, a cancellation, or a refund, never gets a second try. Left unresolved, that drift shows up one of two ways: a customer who paid gets denied access they are owed, which means support tickets, refund requests, and churn, or a customer who canceled or whose payment failed keeps access they are no longer paying for, which is revenue leaking out quietly rather than all at once.
Removing cleanup does not touch revenue, but it is not free either. Read notifications that would normally be purged after retentionDays instead pile up forever in userNotification, which means a steadily rising database bill and, eventually, slower queries as that table outgrows what it was built for.
Neither job is optional the way Ably, PostHog, or Inngest are. Turning either off trades a small, invisible savings today for a cost or revenue problem that surfaces later, after it has already touched customers.
Check for 401 responses in your logs first. A missing or incorrect CRON_SECRET causes every invocation, including the legitimate scheduled one from Vercel, to fail the authorization check silently from the outside, with no visible error unless you are specifically logging unauthorized attempts.
Confirm the reconcile entry is actually present in vercel.json and has not been accidentally removed. Since this job is also the application-level recovery path referenced by the logger’s own queued transport, its absence affects more than just payments.
Check the error column on that row in webhook_events. It is overwritten with the latest failure message on every retry attempt, so it always reflects the most recent reason that event could not be processed, not the original one.
Use crontab.guru to double check a schedule expression before deploying it. A typo here will not error, it will simply run at the wrong time, or not at all, with nothing in your application to tell you that happened.