Skip to content
Docs

Creating Schedules for Each Customer

In a multi-tenant app, each customer (tenant) often needs their own schedules, like a daily digest at a time they choose. Use one namespace per tenant to keep their schedules separate.

Schedule names are unique within a namespace, so every tenant can have a schedule named daily-digest. Use the name for the kind of job and the namespace for the tenant. One queue consumer can then handle every tenant.

Namespaces are only available for dynamic schedules. Static schedules always use the default namespace.

Namespaces follow the same rules as schedule names: 1 to 256 ASCII letters, digits, ., _, and -, starting with a letter or digit.

If your tenant IDs already match these rules, such as tenant_abc123, use them directly. Otherwise, map each ID to a valid string, for example by replacing other characters with -. Make sure the mapping can't give two tenants the same namespace.

When a tenant turns on a recurring job, create a dynamic schedule in their namespace. Store any settings the job needs in the payload:

lib/digest.ts
import { Schedules } from '@vercel/schedules';
 
export async function enableDigest(tenantId: string, timezone: string) {
  await Schedules.create({
    name: 'daily-digest',
    namespace: tenantId,
    target: { topic: 'tenant-jobs' },
    expression: { type: 'cron', cron: '0 9 * * *' },
    timezone,
    payload: { reportType: 'summary' },
  });
}

All tenants can share the tenant-jobs topic. Each message includes the namespace, so your consumer knows which tenant the run is for.

For a one-time job, such as a trial expiring, use a single expression:

await Schedules.create({
  name: 'trial-ending',
  namespace: tenantId,
  target: { topic: 'tenant-jobs' },
  expression: { type: 'single', at: '2026-11-15T09:00' },
  timezone: 'America/New_York',
});

Create one consumer on the shared topic. Read the tenant from message.namespace and the job from message.name:

api/tenant-jobs.ts
import { handleCallback } from '@vercel/queue';
import type { ScheduleQueueMessage } from '@vercel/schedules';
 
type TenantJobMessage = ScheduleQueueMessage<{ reportType?: string }>;
 
export const POST = handleCallback<TenantJobMessage>(async (message) => {
  const tenantId = message.namespace;
 
  switch (message.name) {
    case 'daily-digest':
      await sendDigest(tenantId, message.payload?.reportType ?? 'summary');
      break;
    case 'trial-ending':
      await sendTrialReminder(tenantId);
      break;
  }
});

Subscribe the consumer to the topic in vercel.json:

vercel.json
{
  "functions": {
    "api/tenant-jobs.ts": {
      "experimentalTriggers": [{ "type": "queue/v2beta", "topic": "tenant-jobs" }]
    }
  }
}

Pass the tenant's namespace to every SDK method:

import { Schedules } from '@vercel/schedules';
 
const { data } = await Schedules.list({ namespace: tenantId });
 
await Schedules.update({
  name: 'daily-digest',
  namespace: tenantId,
  timezone: 'America/Los_Angeles',
});
 
await Schedules.invoke({ name: 'daily-digest', namespace: tenantId });

When you remove a tenant, delete all of their schedules so they stop running and no longer count toward your team's schedule limit:

lib/tenants.ts
import { Schedules, SchedulesApiError } from '@vercel/schedules';
 
export async function deleteTenantSchedules(tenantId: string) {
  const names: string[] = [];
  let cursor: string | undefined;
 
  do {
    const page = await Schedules.list({ namespace: tenantId, cursor });
    names.push(...page.data.map((schedule) => schedule.name));
    cursor = page.cursor ?? undefined;
  } while (cursor);
 
  for (const name of names) {
    try {
      await Schedules.delete({ name, namespace: tenantId });
    } catch (error) {
      // A one-time schedule can't be deleted while its run is being dispatched.
      // Edgenivo removes it about 30 days after its scheduled time.
      if (
        error instanceof SchedulesApiError &&
        error.code === 'schedule_single_at_passed'
      ) {
        continue;
      }
      throw error;
    }
  }
}

A one-time schedule can't be deleted while its run is being dispatched, which can take a few minutes after its scheduled time.

Last updated October 7, 2026

Was this helpful?

supported.