---
title: "Scheduled Cron Tasks"
description: "Scheduled Cron Tasks orchestrates Dub's background maintenance, lifecycle automation, data synchronization, and deferred processing operations. Built to handle routine tasks across domains, partner..."
last_updated: "2026-10-12T07:00:10.139631+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/background-processing/scheduled-cron-tasks"
---

<details>
<summary>Relevant source files</summary>

The following files were used as context for generating this wiki page:

- [apps/web/vercel.json](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/vercel.json)
- [apps/web/app/ee/api/cron/sync-redis-resources/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/sync-redis-resources/route.ts)
- [apps/web/app/ee/api/cron/cleanup/orphaned/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/orphaned/route.ts)
- [apps/web/app/ee/api/cron/usage/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/usage/route.ts)
- [apps/web/app/ee/api/cron/cleanup/link-retention/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/link-retention/route.ts)
- [apps/web/app/ee/api/cron/import/tapfiliate/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/import/tapfiliate/route.ts)
- [apps/web/app/ee/api/cron/groups/remap-default-links/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/groups/remap-default-links/route.ts)
- [apps/web/app/ee/api/cron/domains/renewal-reminders/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts)
- [apps/web/app/ee/api/cron/domains/renewal-succeeded/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/domains/renewal-succeeded/route.ts)
- [apps/web/app/ee/api/cron/workspaces/delete/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/workspaces/delete/route.ts)
- [apps/web/app/ee/api/cron/fx-rates/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/fx-rates/route.ts)
- [apps/web/app/ee/api/cron/cleanup/e2e-tests/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/e2e-tests/route.ts)
- [apps/web/app/ee/api/cron/domains/verify/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/domains/verify/route.ts)
- [apps/web/app/ee/api/cron/rewards/queue-custom-commissions/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/rewards/queue-custom-commissions/route.ts)
- [apps/web/app/ee/api/cron/aggregate-clicks/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/aggregate-clicks/route.ts)
- [apps/web/app/ee/api/cron/campaigns/queue-scheduled/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/campaigns/queue-scheduled/route.ts)
- [apps/web/app/ee/api/cron/sitemaps/import/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/sitemaps/import/route.ts)
- [apps/web/app/ee/api/cron/domains/renewal-payments/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/domains/renewal-payments/route.ts)
- [apps/web/app/ee/api/cron/streams/update-click-stats/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/streams/update-click-stats/route.ts)
- [apps/web/app/ee/api/cron/disposable-emails/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/disposable-emails/route.ts)
- [apps/web/app/ee/api/cron/partner-platforms/website/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/partner-platforms/website/route.ts)
- [apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts)
- [apps/web/app/ee/api/cron/cleanup/demo-embed-partners/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/demo-embed-partners/route.ts)
- [apps/web/app/ee/api/cron/bounties/queue-sync-social-metrics/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts)
- [apps/web/app/ee/api/cron/cleanup/expired-fraud-groups/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/expired-fraud-groups/route.ts)
- [apps/web/app/ee/api/cron/payouts/force-withdrawals/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/force-withdrawals/route.ts)
- [apps/web/app/ee/api/cron/program-application-reminder/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/program-application-reminder/route.ts)
- [apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts)
- [apps/web/app/ee/api/cron/partner-program-summary/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/partner-program-summary/route.ts)
- [apps/web/app/api/workspaces/idOrSlug/import/bitly/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/workspaces/%5BidOrSlug%5D/import/bitly/route.ts)
</details>

## Overview

Scheduled Cron Tasks orchestrates Dub's background maintenance, lifecycle automation, data synchronization, and deferred processing operations. Built to handle routine tasks across domains, [partner programs](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/partner-programs/partner-programs), link analytics, and billing, the system relies on scheduled Vercel cron jobs combined with Upstash QStash delivery to secure, queue, and execute backend routines reliably. These workflows solve problems related to database bloat, expired [domain management](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/link-attribution/domain-management), asynchronous metric collection, and partner commission settlement without overloading primary web service routes.

Sources: [apps/web/vercel.json:1-109](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/vercel.json#L1-L109), [apps/web/app/ee/api/cron/cleanup/orphaned/route.ts:1-48](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/orphaned/route.ts#L1-L48), [apps/web/app/ee/api/cron/domains/verify/route.ts:1-108](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/domains/verify/route.ts#L1-L108)

## Execution Middleware and Authentication

### Cron Endpoint Protection and Signature Verification

Cron endpoints expose public HTTP handlers marked with `export const dynamic = "force-dynamic"` to bypass static rendering. To prevent unauthorized invocation, these routes protect their execution entry points using either a higher-order wrapper or direct signature validation against Upstash QStash headers. 

The usage update cron endpoint wraps its handler with `withCron`, which encapsulates authentication checks before invoking the underlying routine:

```typescript
export const POST = withCron(async () => {
  const result = await updateUsage();
  return logAndRespond(result);
});
```
Sources: [apps/web/app/ee/api/cron/usage/route.ts:1-14](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/usage/route.ts#L1-L14)

Conversely, the link retention cleanup endpoint explicitly reads the raw request text to verify its QStash signature before parsing the request payload with Zod:

```typescript
export async function POST(req: Request) {
  try {
    const rawBody = await req.text();

    await verifyQstashSignature({
      req,
      rawBody,
    });

    const { domain: passedDomain } = z
      .object({
        domain: z.string().optional(),
      })
      .parse(JSON.parse(rawBody));
```
Sources: [apps/web/app/ee/api/cron/cleanup/link-retention/route.ts:16-29](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/link-retention/route.ts#L16-L29)

> [!WARNING]
> Failing to supply or validate the raw request body against `verifyQstashSignature` permits arbitrary payload spoofing, bypassing the cryptographic guarantees provided by Upstash QStash delivery channels.

Sources: [apps/web/app/ee/api/cron/cleanup/link-retention/route.ts:18-23](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/link-retention/route.ts#L18-L23)

## Domain Renewal and Lifecycle Automation

### Domain Renewal Reminder Notifications

Dub automates `.link` domain expiration alerts through a daily cron job that evaluates active, non-disabled domains against specific countdown thresholds. The renewal reminder sequence evaluates domains where `autoRenewalDisabledAt` is null across three distinct intervals prior to expiration.

| Reminder Stage | Days Prior to Expiration | Target Date Range Calculation |
| :--- | :--- | :--- |
| First Reminder | 30 days | `subDays(now, -30)` |
| Second Reminder | 23 days | `subDays(now, -23)` |
| Third Reminder | 16 days | `subDays(now, -16)` |

Sources: [apps/web/app/ee/api/cron/domains/renewal-reminders/route.ts:16-54](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts#L16-L54)

When eligible domains are identified, the system loads workspace owner records and maps out reminder payloads containing the domain slug, expiration date, renewal fee, charge date (fixed at 14 days prior to expiration), and human-readable text formatting. Batches of up to 100 emails are dispatched via `sendBatchEmail` using the `DomainRenewalReminder` React-email template with the `notifications` variant.

```typescript
const reminderDomainsChunks = chunk(reminderDomains, 100);

for (const reminderDomainsChunk of reminderDomainsChunks) {
  const res = await sendBatchEmail(
    reminderDomainsChunk.map(({ workspace, user, domain }) => ({
      to: user.email!,
      subject: "Your domain is expiring soon",
      variant: "notifications",
      react: DomainRenewalReminder({
        email: user.email!,
        workspace,
        domain,
      }),
    })),
  );
  console.log(`Sent ${reminderDomainsChunk.length} emails`, res);
}
```
Sources: [apps/web/app/ee/api/cron/domains/renewal-reminders/route.ts:104-120](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/domains/renewal-reminders/route.ts#L104-L120)

### Payment Intent Generation and Invoice Creation

Fourteen days before a registered domain expires, a separate cron routine queries for eligible domains and initiates payment processing. The routine groups domains by workspace ID, generates sequential invoice numbers using workspace-specific prefixes, and records a `domainRenewal` invoice inside a Prisma transaction.

```typescript
const invoice = await prisma.$transaction(async (tx) => {
  const totalInvoices = await tx.invoice.count({
    where: {
      workspaceId: workspace.id,
    },
  });
  const paddedNumber = String(totalInvoices + 1).padStart(4, "0");
  const invoiceNumber = `${workspace.invoicePrefix}-${paddedNumber}`;

  const totalAmount = domains.reduce(
    (acc, domain) => acc + domain.renewalFee,
    0,
  );

  return await tx.invoice.create({
    data: {
      id: createId({ prefix: "inv_" }),
      workspaceId: workspace.id,
      number: invoiceNumber,
      type: "domainRenewal",
      amount: totalAmount,
      total: totalAmount,
      registeredDomains: domains.map(({ slug }) => slug), // array of domain slugs,
    },
  });
});
```
Sources: [apps/web/app/ee/api/cron/domains/renewal-payments/route.ts:30-117](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/domains/renewal-payments/route.ts#L30-L117)

> [!NOTE]
> If a renewal payment targets the special `ACME_WORKSPACE_ID`, the routine automatically substitutes its Stripe configuration with the `DUB_WORKSPACE_ID` stripe identifier to bill against the correct platform account.

Sources: [apps/web/app/ee/api/cron/domains/renewal-payments/route.ts:131-147](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/domains/renewal-payments/route.ts#L131-L147)

Following invoice creation, Stripe payment intents are dispatched for each invoice with the statement descriptor `DUB.CO DOMAIN RENEWAL`, utilizing an idempotency key composed of the invoice identifier and its failed attempt count.

Sources: [apps/web/app/ee/api/cron/domains/renewal-payments/route.ts:127-164](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/domains/renewal-payments/route.ts#L127-L164)

### Success Confirmation and Registrar Extension

Upon successful payment settlement, the `renewal-succeeded` cron routine validates the incoming payload against an `invoiceId` schema, ensuring the invoice is marked as `completed` with a valid `paidAt` timestamp and a type of `domainRenewal`.

```typescript
const inputSchema = z.object({
  invoiceId: z.string(),
});
```
Sources: [apps/web/app/ee/api/cron/domains/renewal-succeeded/route.ts:17-25](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/domains/renewal-succeeded/route.ts#L17-L25)

The service parses domain slugs from the invoice record and processes renewals in batches of 5. For each domain, it invokes `setRenewOption` against the Dynadot registrar API, calculates the new expiration date by extending the term by 365 days from either the existing expiration date or today (whichever is later), and updates the database record while clearing any prior `autoRenewalDisabledAt` flags.

```typescript
const renewalBase =
  domain.expiresAt > todayStart ? domain.expiresAt : todayStart;

succeeded.push({
  domain,
  newExpiresAt: addDays(renewalBase, 365),
});
```
Sources: [apps/web/app/ee/api/cron/domains/renewal-succeeded/route.ts:98-129](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/domains/renewal-succeeded/route.ts#L98-L129)

> [!CAUTION]
> If any domain update fails against Dynadot during batch execution, the cron routine records an error log, triggers an internal system notification mentioning relevant personnel, and throws an exception to abort execution and force a retry.

Sources: [apps/web/app/ee/api/cron/domains/renewal-succeeded/route.ts:155-170](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/domains/renewal-succeeded/route.ts#L155-L170)

If all domains renew successfully, workspace owners receive a transactional success email using the `DomainRenewed` React-email template, displaying the earliest new expiration date across all renewed domains in the batch.

Sources: [apps/web/app/ee/api/cron/domains/renewal-succeeded/route.ts:178-225](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/domains/renewal-succeeded/route.ts#L178-L225)

## Data Cleanup and Cascade Deletion

### Link Retention and Expired Fraud Groups

The link retention cleanup routine executes every 12 hours via the cron expression `0 */12 * * *` to purge outdated links for domains configured with a retention policy. The handler verifies incoming requests via `verifyQstashSignature` and processes either all matching domains or a specific domain slug passed in the request body.

```typescript
// This route is used to delete old links for domains with linkRetentionDays set
// Runs once every 12 hours (0 */12 * * *)
// POST /api/cron/cleanup/link-retention
export async function POST(req: Request) {
  try {
    const rawBody = await req.text();

    await verifyQstashSignature({
      req,
      rawBody,
    });

    const { domain: passedDomain } = z
      .object({
        domain: z.string().optional(),
      })
      .parse(JSON.parse(rawBody));

    if (!passedDomain) {
      const domains = await prisma.domain.findMany({
        where: {
          linkRetentionDays: {
            not: null,
          },
        },
      });
      await Promise.all(domains.map((domain) => deleteOldLinks(domain)));
    } else {
      const domain = await prisma.domain.findUniqueOrThrow({
        where: {
          slug: passedDomain,
        },
      });

      await deleteOldLinks(domain);
    }

```
Sources: [apps/web/app/ee/api/cron/cleanup/link-retention/route.ts:13-49](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/link-retention/route.ts#L13-L49)

For each domain, the worker queries up to 10 batches of 100 links (`LINKS_PER_BATCH = 100`, `MAX_LINK_BATCHES = 10`) created earlier than the retention threshold (`domain.linkRetentionDays`) and lacking any `linkRetentionCleanupDisabledAt` timestamp. Links are deleted in bulk using `bulkDeleteLinks`, with a 250ms pause between batches. If the batch limit is reached and more links remain (`hasMoreLinks`), the worker publishes a continuation job back to QStash for the same domain slug.

```typescript
const LINKS_PER_BATCH = 100;
const MAX_LINK_BATCHES = 10;

async function deleteOldLinks(
  domain: Pick<Domain, "id" | "slug" | "linkRetentionDays" | "projectId">,
) {
  if (
    !domain.linkRetentionDays ||
    !domain.projectId ||
    domain.linkRetentionDays <= 0
  )
    return;

  let processedBatches = 0;
  let hasMoreLinks = false;

  while (processedBatches < MAX_LINK_BATCHES) {
    const links = await prisma.link.findMany({
      where: {
        domain: domain.slug,
        createdAt: {
          lt: new Date(
            Date.now() - 1000 * 60 * 60 * 24 * domain.linkRetentionDays,
          ),
        },
        linkRetentionCleanupDisabledAt: null,
      },
      orderBy: {
        createdAt: "asc",
      },
      take: LINKS_PER_BATCH,
    });

    console.log(
      `[Link retention cleanup] Found ${links.length} links to delete for ${domain.slug} that are older than ${domain.linkRetentionDays} days`,
    );

    if (links.length === 0) break;

    // Check if we might have more links (if we got a full batch)
    hasMoreLinks = links.length === LINKS_PER_BATCH;

    console.log(
      `[Link retention cleanup] Deleting ${links.length} links for ${domain.slug} (batch ${processedBatches + 1})...`,
    );

    console.table(links, ["shortLink", "createdAt"]);

    await bulkDeleteLinks(links);

    ++processedBatches;

    // sleep for 250ms
    await sleep(250);
  }

  // Only schedule another run if we hit the batch limit AND we found a full batch
  // (indicating there might be more links to process)
  if (processedBatches >= MAX_LINK_BATCHES && hasMoreLinks) {
    await qstash.publishJSON({
      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/cleanup/link-retention`,
      method: "POST",
      body: {
        domain: domain.slug,
      },
    });
  }
```
Sources: [apps/web/app/ee/api/cron/cleanup/link-retention/route.ts:56-122](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/link-retention/route.ts#L56-L122)

> [!WARNING]
> Links possessing an active `linkRetentionCleanupDisabledAt` flag are explicitly excluded from retention purges, protecting preserved links from automated deletion even if they exceed the domain's retention window.

Sources: [apps/web/app/ee/api/cron/cleanup/link-retention/route.ts:73-82](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/link-retention/route.ts#L73-L82)

Separately, the expired fraud groups cleanup job runs daily at 02:30 AM UTC (`30 2 * * *`) to expire pending fraud event groups that have remained inactive past their expiration window.

```typescript
// Runs once every day at 02:30:00 AM UTC (30 2 * * *)
// POST /api/cron/cleanup/expired-fraud-groups
export const POST = withCron(async () => {
  let expiredCount = 0;
  while (true) {
    const groupsToExpire = await prisma.fraudEventGroup.findMany({
      where: {
        status: "pending",
        type: {
          notIn: NON_EXPIRING_FRAUD_RULE_TYPES,
        },
        lastEventAt: {
          lt: subDays(new Date(), FRAUD_GROUP_EXPIRY_DAYS),
        },
      },
      select: {
        id: true,
      },
      take: BATCH_SIZE,
    });
```
Sources: [apps/web/app/ee/api/cron/cleanup/expired-fraud-groups/route.ts:17-36](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/expired-fraud-groups/route.ts#L17-L36)

The routine queries `FraudEventGroup` records in batches of 250 where the status is `pending`, the type is not included in `NON_EXPIRING_FRAUD_RULE_TYPES`, and `lastEventAt` falls past `FRAUD_GROUP_EXPIRY_DAYS`. Matching groups are updated to an `expired` status, triggering a queued release of hold commissions via `queueReleaseHoldCommissions`.

```typescript
const { count } = await prisma.fraudEventGroup.updateMany({
  where: {
    id: {
      in: groupIds,
    },
    status: "pending",
  },
  data: {
    status: "expired",
  },
});

expiredCount += count;
console.log(`Expired ${count} fraud groups`);

if (count > 0) {
  await queueReleaseHoldCommissions(groupIds);
}
```
Sources: [apps/web/app/ee/api/cron/cleanup/expired-fraud-groups/route.ts:44-61](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/expired-fraud-groups/route.ts#L44-L61)

### Orphaned Records and Test Fixture Purges

The orphaned record cleanup cron routine (`POST /api/cron/cleanup/orphaned`) hard-deletes leftover database rows that were previously soft-deleted or unassigned during request-path operations. Utilizing a staleness cutoff of 60 minutes (`STALE_AFTER_MINUTES = 60`), it executes four parallel cleanup handlers via `Promise.all`:

```typescript
export const POST = withCron(async () => {
  const cutoff = subMinutes(new Date(), STALE_AFTER_MINUTES);

  const [
    deletedRewardsCount,
    deletedDiscountsCount,
    deletedDefaultLinksCount,
    deletedLinkRewardsCount,
  ] = await Promise.all([
    deleteOrphanedRewards(cutoff),
    deleteOrphanedDiscounts(cutoff),
    deleteOrphanedDefaultLinks(cutoff),
    deleteEmptyLinkRewards(),
  ]);
```
Sources: [apps/web/app/ee/api/cron/cleanup/orphaned/route.ts:25-38](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/orphaned/route.ts#L25-L38)

| Cleanup Function | Target Entity | Removal Condition / Mechanism |
| :--- | :--- | :--- |
| `deleteOrphanedRewards` | Rewards | Hard-deleted once enrollments are cleared and no references remain (`programId` is null). |
| `deleteOrphanedDiscounts` | Discounts | Hard-deleted once codes are detached and no references remain (`programId` is null). |
| `deleteOrphanedDefaultLinks` | PartnerGroupDefaultLinks | Hard-deleted once group deletion unassigns the row (`groupId` is null) and no links reference it. |
| `deleteEmptyLinkRewards` | LinkRewards | Purged when override IDs or discount IDs have been nullified. |

Sources: [apps/web/app/ee/api/cron/cleanup/orphaned/route.ts:13-38](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/orphaned/route.ts#L13-L38)

Test fixture cleanup runs every 6 hours to purge resources created during end-to-end testing, scoped to specific test constants (`E2E_USER_ID = "clxz1q7c7000hbqx5ckv4r82h"`, `E2E_WORKSPACE_ID = "clrei1gld0002vs9mzn93p8ik"`).

```typescript
const E2E_USER_ID = "clxz1q7c7000hbqx5ckv4r82h";
const E2E_WORKSPACE_ID = "clrei1gld0002vs9mzn93p8ik";

// This route is used to remove links, domains and tags created during our E2E tests.
// Runs every 6 hours (0 * / 6 * * *)
export async function POST(req: Request) {
  try {
    const rawBody = await req.text();

    await verifyQstashSignature({
      req,
      rawBody,
    });

    const oneDayAgo = new Date(Date.now() - 1000 * 60 * 60 * 24);

    const [links, domains, tags, partners, users] = await Promise.all([
      prisma.link.findMany({
        where: {
          userId: E2E_USER_ID,
          projectId: E2E_WORKSPACE_ID,
          createdAt: {
            lt: oneDayAgo,
          },
        },
        include: {
          ...includeTags,
          ...includeProgramEnrollment,
        },
        take: 100,
      }),

      prisma.domain.findMany({
        where: {
          projectId: E2E_WORKSPACE_ID,
          slug: {
            endsWith: ".dub-internal-test.com",
          },
          createdAt: {
            lt: oneDayAgo,
          },
        },
        select: {
          slug: true,
        },
      }),

      prisma.tag.findMany({
        where: {
          projectId: E2E_WORKSPACE_ID,
          name: {
            startsWith: "e2e-",
          },
          createdAt: {
            lt: oneDayAgo,
          },
        },
      }),

      prisma.partner.findMany({
        where: {
          email: {
            endsWith: "@dub-internal-test.com",
          },
          createdAt: {
            lt: oneDayAgo,
          },
        },
        select: {
          id: true,
        },
      }),

      prisma.user.findMany({
        where: {
          email: {
            endsWith: "@dub-internal-test.com",
          },
          createdAt: {
            lt: oneDayAgo,
```
Sources: [apps/web/app/ee/api/cron/cleanup/e2e-tests/route.ts:14-97](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/e2e-tests/route.ts#L14-L97)

The worker queries records older than one day (`oneDayAgo`) matching test patterns across five tables and disposes of them using specific deletion workflows: links via `bulkDeleteLinks`, domains ending in `.dub-internal-test.com` via `markDomainAsDeleted`, tags starting with `e2e-` via `prisma.tag.deleteMany`, partners ending in `@dub-internal-test.com` via `bulkDeletePartners`, and test users via `prisma.user.deleteMany`.

```typescript
// Delete the links
if (links.length > 0) {
  await bulkDeleteLinks(links);
}

// Delete the domains
if (domains.length > 0) {
  await Promise.all(
    domains.map(({ slug }) =>
      markDomainAsDeleted({
        domain: slug,
      }),
    ),
  );
}

// Delete the tags
if (tags.length > 0) {
  await prisma.tag.deleteMany({
    where: {
      id: {
        in: tags.map((tag) => tag.id),
      },
    },
  });
}

// Delete the partners
if (partners.length > 0) {
  await bulkDeletePartners({
    partnerIds: partners.map((partner) => partner.id),
    deletePartners: true,
  });
}

if (users.length > 0) {
  await prisma.user.deleteMany({
    where: {
      id: {
        in: users.map((user) => user.id),
      },
    },
  });
}
```
Sources: [apps/web/app/ee/api/cron/cleanup/e2e-tests/route.ts:99-142](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/e2e-tests/route.ts#L99-L142)

## Partner and Affiliate Synchronization

### Affiliate Platform Ingestion Routes

Dub coordinates external affiliate platform synchronizations through dedicated cron endpoints that ingest payloads using Zod schemas before dispatching them to specific handler functions. The Tapfiliate import route (`POST /api/cron/import/tapfiliate`) parses incoming JSON bodies against `tapfiliateImportPayloadSchema` and routes execution via a `switch` statement on `payload.action`.

```typescript
export const POST = withCron(async ({ rawBody }) => {
  const payload = tapfiliateImportPayloadSchema.parse(JSON.parse(rawBody));

  switch (payload.action) {
    case "import-groups":
      await importGroups(payload);
      break;
    case "import-partners":
      await importPartners(payload);
      break;
    case "import-customers":
      await importCustomers(payload);
      break;
    case "import-commissions":
      await importCommissions(payload);
      break;
    case "update-stripe-customers":
      await updateStripeCustomers(payload);
      break;
    case "cleanup-partners":
      await cleanupPartners(payload);
      break;
  }
```
Sources: [apps/web/app/ee/api/cron/import/tapfiliate/route.ts:13-35](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/import/tapfiliate/route.ts#L13-L35)

| Tapfiliate Action | Handler Function | Purpose |
| :--- | :--- | :--- |
| `import-groups` | `importGroups(payload)` | Synchronizes affiliate groups and tier hierarchies. |
| `import-partners` | `importPartners(payload)` | Imports affiliate profiles and associates them with partner records. |
| `import-customers` | `importCustomers(payload)` | Syncs referred customers and referral associations. |
| `import-commissions` | `importCommissions(payload)` | Ingests historical or pending commission records. |
| `update-stripe-customers` | `updateStripeCustomers(payload)` | Reconciles external customer identifiers with Stripe billing data. |
| `cleanup-partners` | `cleanupPartners(payload)` | Removes orphaned or deactivated import artifacts. |

Sources: [apps/web/app/ee/api/cron/import/tapfiliate/route.ts:16-34](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/import/tapfiliate/route.ts#L16-L34)

Similarly, the Lemon Squeezy import route (`POST /api/cron/import/lemonsqueezy`) validates requests using `lemonSqueezyImportPayloadSchema` and routes actions to platform-specific modules.

```typescript
export const POST = withCron(async ({ rawBody }) => {
  const payload = lemonSqueezyImportPayloadSchema.parse(JSON.parse(rawBody));

  switch (payload.action) {
    case "import-partners":
      await importPartners(payload);
      break;
    case "import-customers":
      await importCustomers(payload);
      break;
    case "import-commissions":
      await importCommissions(payload);
      break;
  }
```
Sources: [apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts:10-23](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts#L10-L23)

| Lemon Squeezy Action | Handler Function | Purpose |
| :--- | :--- | :--- |
| `import-partners` | `importPartners(payload)` | Ingests affiliate profiles from Lemon Squeezy. |
| `import-customers` | `importCustomers(payload)` | Syncs customer transactions and buyer references. |
| `import-commissions` | `importCommissions(payload)` | Processes imported affiliate earnings and payouts. |

Sources: [apps/web/app/ee/api/cron/import/lemonsqueezy/route.ts:13-22](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/import/lemonsqueezy/route.ts#L13-L22)

### Default Link Remapping and Website Ratings

When partners are moved between groups, the default link remapping job (`POST /api/cron/groups/remap-default-links`) verifies QStash signatures and extracts `programId`, `groupId`, `partnerIds`, and `userId` to synchronize partner link assignments against new group configurations.

```typescript
export async function POST(req: Request) {
  try {
    const rawBody = await req.text();
    await verifyQstashSignature({ req, rawBody });

    const { programId, groupId, partnerIds, userId } = schema.parse(
      JSON.parse(rawBody),
    );
```
Sources: [apps/web/app/ee/api/cron/groups/remap-default-links/route.ts:42-49](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/groups/remap-default-links/route.ts#L42-L49)

> [!NOTE]
> If an emptied source group is deleted before remapping finishes, its default links are soft-deleted with a null `groupId`, ensuring `partnerGroupDefaultLinkId` remains intact until the background job completes the remapping lifecycle.

Sources: [apps/web/app/ee/api/cron/groups/remap-default-links/route.ts:36-39](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/groups/remap-default-links/route.ts#L36-L39)

To keep partner platform metrics updated, the website rating cron route (`GET /api/cron/partner-platforms/website`) runs once per minute to update Domain Rating (DR) metrics for verified website partners using the Ahrefs API.

```typescript
export const GET = withCron(async () => {
  const websites = await prisma.partnerPlatform.findMany({
    where: {
      type: PlatformType.website,
      verifiedAt: {
        not: null,
      },
      // only check websites that haven't been checked in the last 24 hours
      lastCheckedAt: {
        lt: new Date(Date.now() - 1000 * 60 * 60 * 24),
      },
    },
    take: BATCH_SIZE,
    orderBy: {
      lastCheckedAt: "asc",
    },
  });
```
Sources: [apps/web/app/ee/api/cron/partner-platforms/website/route.ts:18-34](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/partner-platforms/website/route.ts#L18-L34)

The worker processes platforms with strict concurrency and batch controls (`BATCH_SIZE = 60`, `CONCURRENCY = 5`), selecting records where `type` is `website`, `verifiedAt` is not null, and `lastCheckedAt` exceeds 24 hours.

```typescript
const BATCH_SIZE = 60;
const CONCURRENCY = 5;
…
const websiteChunks = chunk(websites, CONCURRENCY);

for (const websiteChunk of websiteChunks) {
```
Sources: [apps/web/app/ee/api/cron/partner-platforms/website/route.ts:10-11](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/partner-platforms/website/route.ts#L10-L11), [apps/web/app/ee/api/cron/partner-platforms/website/route.ts:40-42](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/partner-platforms/website/route.ts#L40-L42)

### Demo Embed Partner Maintenance

The demo embed cleanup cron (`POST /api/cron/cleanup/demo-embed-partners`) runs hourly to purge non-whitelisted program enrollments from the Acme demo program (`ACME_PROGRAM_ID`) created over one hour ago.

```typescript
export async function POST(req: Request) {
  try {
    const rawBody = await req.text();

    await verifyQstashSignature({
      req,
      rawBody,
    });

    const programEnrollmentsToDelete = await prisma.programEnrollment.findMany({
      where: {
        programId: ACME_PROGRAM_ID,
        createdAt: {
          lt: subHours(new Date(), 1), // 1 hour ago
        },
```
Sources: [apps/web/app/ee/api/cron/cleanup/demo-embed-partners/route.ts:13-27](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/demo-embed-partners/route.ts#L13-L27)

> [!WARNING]
> Program enrollments associated with internal test email domains (`@dub.co`, `@dub-internal-test.com`) and specific whitelisted fixture accounts are explicitly excluded from deletion, protecting core showcase data from being purged by the hourly cycle.

Sources: [apps/web/app/ee/api/cron/cleanup/demo-embed-partners/route.ts:28-58](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/cleanup/demo-embed-partners/route.ts#L28-L58)

## Commission Aggregation and Payout Triggers

### Periodic Commission Aggregation and Batching

The aggregate due commissions cron route (`GET /api/cron/payouts/aggregate-due-commissions`) runs once every hour (`0 * * * *`) with a maximum execution duration of 600 seconds to convert pending commissions past their holding period into structured payouts. 

```typescript
export const maxDuration = 600;
export const dynamic = "force-dynamic";

// This cron job aggregates due commissions (pending commissions that are past the partner group's holding period) into payouts.
// Runs once every hour (0 * * * *) + calls itself recursively to look through all pending commissions available.
// GET /api/cron/payouts/aggregate-due-commissions
export const GET = withCron(async () => {
```
Sources: [apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/route.ts:9-15](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts#L9-L15)

The handler queries programs that contain at least one commission with a `pending` status, extracts their unique identifiers, and chunks them into groups of 50 to prevent database exhaustion.

```typescript
const programsWithPendingCommissions = await prisma.program.findMany({
  where: {
    commissions: {
      some: {
        status: CommissionStatus.pending,
      },
    },
  },
  select: {
    id: true,
  },
});

const programIds = programsWithPendingCommissions.map((p) => p.id);

if (programIds.length === 0) {
  return logAndRespond("No programs with due commissions found. Skipping...");
}

console.log(`Found ${programIds.length} programs with pending commissions.`);

// process 50 programs at a time to avoid overwhelming the database
const programIdChunks = chunk(programIds, 50);
```
Sources: [apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/route.ts:16-38](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts#L16-L38)

Each program chunk is dispatched to Upstash QStash via `qstash.batchJSON` with a staggered delay of 10 seconds per chunk index (`index * 10`). Every published job specifies a strict deduplication ID and flow control configuration with a parallelism limit of 1.

```typescript
for (const [index, programIdChunk] of programIdChunks.entries()) {
  const delaySeconds = index * 10; // delay by 10s between program chunks

  const jobs: PublishBatchRequest<{ programId: string }>[] =
    programIdChunk.map((programId) => ({
      url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/aggregate-due-commissions/process`,
      body: {
        programId,
      },
      deduplicationId: `aggregate-due-commissions-${programId}`,
      label: "aggregate-due-commissions",
      flowControl: {
        key: `aggregate-due-commissions-${programId}`,
        parallelism: 1,
      },
      delay: delaySeconds,
    }));

  await qstash.batchJSON(jobs);

  console.log(
    `Enqueued index ${index + 1}/${programIdChunks.length} jobs to be processed (with ${delaySeconds}s delay).`,
  );
}
```
Sources: [apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/route.ts:40-63](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts#L40-L63)

> [!NOTE]
> The deduplication identifier format `aggregate-due-commissions-${programId}` ensures that concurrent or overlapping execution attempts for the same program are safely collapsed by QStash.

Sources: [apps/web/app/ee/api/cron/payouts/aggregate-due-commissions/route.ts:49-54](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts#L49-L54)

### Force Withdrawals and Account Reminders

To handle stagnant affiliate funds, the force-withdrawals cron route (`GET/POST /api/cron/payouts/force-withdrawals`) runs daily at 5:00 AM PST (`0 12 * * *`). It processes partners whose processed payouts meet or exceed `MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS` and have remained unwithdrawn for more than 90 days.

```typescript
// This route is used to force withdrawals for partners that haven't withdrew their earnings for than 90 days
// Runs once a day at 5AM PST (0 12 * * *) + calls itself recursively to process all partners in batches
// GET/POST /api/cron/payouts/force-withdrawals

export const GET = withCron(async ({ rawBody }) => {
  let { startingAfter } = schema.parse(rawBody ? JSON.parse(rawBody) : {});

  // Get batch of partners with processed payouts (cursor-based pagination)
  const partnersToProcess = await prisma.partner.findMany({
    where: {
      payoutsEnabledAt: {
        not: null,
      },
      defaultPayoutMethod: {
        in: ["stablecoin", "connect"],
      },
      payouts: {
        some: {
          status: "processed",
          amount: {
            gte: MIN_FORCE_WITHDRAWAL_AMOUNT_CENTS,
          },
          paidAt: {
            lte: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000), // 90 days ago
          },
        },
      },
```
Sources: [apps/web/app/ee/api/cron/payouts/force-withdrawals/route.ts:18-44](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/force-withdrawals/route.ts#L18-L44)

| Parameter / Constant | Value / Type | Purpose |
| :--- | :--- | :--- |
| `BATCH_SIZE` | `20` | Number of partner records processed per execution batch. |
| `defaultPayoutMethod` | `["stablecoin", "connect"]` | Eligible withdrawal channels for forced settlement. |
| `payouts.status` | `"processed"` | Filter requiring payouts to be marked as processed. |
| `paidAt` | `lte: 90 days ago` | Age threshold triggering mandatory withdrawal action. |

Sources: [apps/web/app/ee/api/cron/payouts/force-withdrawals/route.ts:10-44](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/force-withdrawals/route.ts#L10-L44)

The router processes partners in batches of 20 using cursor-based pagination sorted by ascending identifier (`id: "asc"`). If more records remain (`hasMoreToProcess`), it recursively enqueues the next batch via QStash using the last processed partner's identifier as `startingAfter`.

```typescript
  take: BATCH_SIZE,
  orderBy: {
    id: "asc",
  },
  ...(startingAfter && {
    skip: 1,
    cursor: {
      id: startingAfter,
    },
  }),
  select: {
    id: true,
    defaultPayoutMethod: true,
  },
});

if (!partnersToProcess.length) {
  return logAndRespond(
    "No partners to process. Skipping force withdrawals...",
  );
}

const hasMoreToProcess = partnersToProcess.length === BATCH_SIZE;

console.log(
  `Found ${partnersToProcess.length} partners to process${hasMoreToProcess ? " (more to process)" : ""}`,
);

await Promise.allSettled(
  partnersToProcess.map((partner) => forceWithdrawal(partner)),
);

if (hasMoreToProcess) {
  console.log(
    "More partners need force withdrawals, scheduling next batch...",
  );

  const nextStartingAfter =
    partnersToProcess[partnersToProcess.length - 1].id;

  const qstashResponse = await qstash.publishJSON({
    url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/force-withdrawals`,
    method: "POST",
    body: {
      startingAfter: nextStartingAfter,
    },
  });
```
Sources: [apps/web/app/ee/api/cron/payouts/force-withdrawals/route.ts:46-92](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/payouts/force-withdrawals/route.ts#L46-L92)

In parallel, the program application reminder job route (`POST /api/cron/program-application-reminder`) executes `programApplicationReminderJob` to target unverified program applicants [Email Notification](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/background-processing/email-notification).

```typescript
// POST - /api/cron/program-application-reminder
// Sends an email to a program application email if they haven't verified their account on Dub yet
// TODO: Remove this route once in-flight QStash messages to this URL have drained (~3 days after deploy)
export const POST = withCron(async ({ rawBody }) => {
  await programApplicationReminderJob.execute(JSON.parse(rawBody));
  return logAndRespond("Program application reminder processed.");
```
Sources: [apps/web/app/ee/api/cron/program-application-reminder/route.ts:7-12](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/program-application-reminder/route.ts#L7-L12)

## External Ingestion and Telemetry Sync

### Foreign Exchange Rates and Email Blocklists

The foreign exchange synchronization task (`POST /api/cron/fx-rates`) runs daily at 08:00 AM UTC (`0 8 * * *`) to fetch latest currency values from `currencyApiClient.getLatest()`. It transforms the response entries into a ticker-to-value map and stores them in Redis under the hash key `fxRates:usd`.

```typescript
export const POST = withCron(async () => {
  const { data } = await currencyApiClient.getLatest();

  const transformedRates: Record<string, number> = {};

  for (const [ticker, details] of Object.entries(data)) {
    transformedRates[ticker] = details.value;
  }

  // Store FX rates in Redis (with USD as the base currency)
  await redis.hset("fxRates:usd", transformedRates);

  return logAndRespond(
    `Updated ${Object.keys(transformedRates).length} FX rates.`,
  );
```
Sources: [apps/web/app/ee/api/cron/fx-rates/route.ts:11-25](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/fx-rates/route.ts#L11-L25)

Similarly, the disposable email synchronization task (`POST /api/cron/disposable-emails`) runs every Monday at noon UTC (`0 12 * * 1`) to ingest domain blocklists from external sources and update Redis sets without downtime [Caching & Rate Limiting](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/core-architecture/caching-rate-limiting).

```typescript
export const POST = withCron(async () => {
  const [disposableRes, tremendousRes] = await Promise.allSettled([
    fetch(
      "https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/master/disposable_email_blocklist.conf",
    ),
    fetch("https://api.tremendous.com/prohibited_email_domains.txt"),
  ]);

  if (disposableRes.status === "rejected") {
    console.warn(
      `Failed to fetch disposable email domains list: ${disposableRes.status} ${disposableRes.reason}`,
    );
  }

  if (tremendousRes.status === "rejected") {
    console.warn(
      `Failed to fetch Tremendous prohibited email domains list: ${tremendousRes.status} ${tremendousRes.reason}`,
    );
  }

  const disposableDomains =
    disposableRes.status === "fulfilled"
      ? (await disposableRes.value.text()).split("\n").filter(Boolean)
      : [];

  const tremendousDomains =
    tremendousRes.status === "fulfilled"
      ? (await tremendousRes.value.text())
          .split("\n")
          .map((d) => d.trim().toLowerCase())
          .filter(Boolean)
      : [];

  if (disposableDomains.length > 0) {
    // Use temporary sets to avoid emptying the old sets
    await redis.del("disposableEmailDomainsTmp");
    await redis.sadd(
      "disposableEmailDomainsTmp",
      ...(disposableDomains as [string]),
    );
    await redis.rename("disposableEmailDomainsTmp", "disposableEmailDomains");
  }

  if (tremendousDomains.length > 0) {
    await redis.del("tremendousProhibitedEmailDomainsTmp");
    await redis.sadd(
      "tremendousProhibitedEmailDomainsTmp",
      ...(tremendousDomains as [string]),
    );
    await redis.rename(
      "tremendousProhibitedEmailDomainsTmp",
      "tremendousProhibitedEmailDomains",
    );
  }

  return logAndRespond(
    `Synced ${disposableDomains.length} disposable and ${tremendousDomains.length} Tremendous prohibited email domains.`,
  );
```
Sources: [apps/web/app/ee/api/cron/disposable-emails/route.ts:10-67](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/disposable-emails/route.ts#L10-L67)

| Source URL | Redis Temporary Set | Redis Final Set Key | Purpose |
| :--- | :--- | :--- | :--- |
| `disposable_email_blocklist.conf` | `disposableEmailDomainsTmp` | `disposableEmailDomains` | Blocks temporary disposable email domains. |

## Related

- [Queue Workers](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/background-processing/queue-workers)
- [Payout Settlement](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/billing-and-payouts/payout-settlement)


## Sitemap

See the full [sitemap](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/llms.txt) for all pages in this wiki.
