---
title: "A/B Testing"
description: "Dub provides built-in A/B testing capabilities that allow links to split incoming traffic across multiple destination URLs based on percentage weights. When configured, edge routing inspects user c..."
last_updated: "2026-10-12T07:00:10.151461+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/link-attribution/a-b-testing"
---

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

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

- [apps/web/lib/middleware/link.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/link.ts)
- [apps/web/lib/middleware/utils/resolve-ab-test-url.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/utils/resolve-ab-test-url.ts)
- [apps/web/middleware.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/middleware.ts)
- [apps/web/app/ee/api/cron/campaigns/broadcast/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/campaigns/broadcast/route.ts)
- [apps/web/app/ee/api/track/visit/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/track/visit/route.ts)
- [apps/web/app/ee/api/track/application/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/track/application/route.ts)
- [apps/web/app/ee/api/track/click/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/track/click/route.ts)
- [apps/web/app/api/dub/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/dub/webhook/route.ts)
- [apps/web/app/ee/api/appsflyer/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/appsflyer/webhook/route.ts)
- [apps/web/app/ee/api/cron/domains/update/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/domains/update/route.ts)
- [apps/web/app/ee/api/track/open/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/track/open/route.ts)
- [apps/web/lib/api/links/complete-ab-tests.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/links/complete-ab-tests.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/lib/api/links/ab-test-scheduler.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/links/ab-test-scheduler.ts)
- [apps/web/scripts/dub-wrapped.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/scripts/dub-wrapped.ts)
- [apps/web/app/api/links/random/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/links/random/route.ts)
- [apps/web/app/ee/api/cron/import/csv/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/import/csv/route.ts)
- [apps/web/app/domain/expired/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/%5Bdomain%5D/expired/page.tsx)
- [apps/web/app/ee/api/hubspot/webhook/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/hubspot/webhook/route.ts)
- [apps/web/app/ee/api/cron/sitemaps/queue/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/sitemaps/queue/route.ts)
- [apps/web/app/api/links/metatags/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/links/metatags/route.ts)
- [apps/web/app/ee/api/cron/links/linkId/complete-tests/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/links/%5BlinkId%5D/complete-tests/route.ts)
- [apps/web/app/api/links/iframeable/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/links/iframeable/route.ts)
- [apps/web/lib/dub.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/dub.ts)
- [apps/web/lib/middleware/utils/crawl-bitly.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/utils/crawl-bitly.ts)
- [apps/web/app/app.dub.co/onboarding/slug/wrapped/year/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(onboarding)/%5Bslug%5D/wrapped/%5Byear%5D/page.tsx)
- [apps/web/app/app.dub.co/onboarding/slug/wrapped/page.tsx](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/app.dub.co/(onboarding)/%5Bslug%5D/wrapped/page.tsx)
- [apps/web/lib/upstash/ratelimit.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/upstash/ratelimit.ts)
- [apps/web/app/ee/api/cron/trial-emails/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/trial-emails/route.ts)
- [apps/web/lib/tinybird/record-click.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/tinybird/record-click.ts)
</details>

## Overview

Dub provides built-in A/B testing capabilities that allow links to split incoming traffic across multiple destination URLs based on percentage weights. When configured, edge routing inspects user cookies or performs a weighted random allocation to select a variant URL, ensuring sticky routing for returning visitors during active testing windows. Automated test evaluation and expiration are coordinated via QStash cron jobs that trigger when a test concludes. Upon test completion, analytics data from Tinybird is queried to evaluate conversion metrics, automatically select the winning destination URL, rewrite the link, and dispatch workspace webhooks.

Sources: [apps/web/lib/middleware/link.ts:163-174](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/link.ts#L163-L174), [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:8-58](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L8-L58), [apps/web/lib/api/links/ab-test-scheduler.ts:6-23](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/links/ab-test-scheduler.ts#L6-L23), [apps/web/app/ee/api/cron/links/linkId/complete-tests/route.ts:8-45](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/links/%5BlinkId%5D/complete-tests/route.ts#L8-L45), [apps/web/lib/api/links/complete-ab-tests.ts:12-89](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/links/complete-ab-tests.ts#L12-L89)

## A/B Testing Variant Schema

### Validation Constraints and Thresholds

Variant configurations undergo verification before any weight calculations occur. A link must supply a valid `testVariants` collection and an active `testCompletedAt` expiration timestamp that remains in the future (`new Date(testCompletedAt) > new Date()`). If either condition fails, variant resolution immediately returns `null`.

> [!WARNING]
> A test variant configuration is considered invalid if the total number of configured items is less than `2` or exceeds the allowed variant count. When this condition triggers, an error is emitted via `console.error` and resolution aborts.

Sources: [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:16-27](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L16-L27)

### Percentage Weight and Selection Rules

Once structural checks pass, the selection engine checks for an existing sticky routing cookie named `dub_test_url`. If present and its value matches one of the candidate variant URLs in `testVariants.map((t) => t.url)`, that exact URL is returned without executing random selection. 

When no valid tracking cookie exists, the system computes percentage weights dynamically from the variant array. The first variant's percentage establishes the initial weight, and subsequent entries accumulate weight additively through a cumulative summation loop. A random floating-point value is generated between `0` and the final cumulative weight using `Math.random() * weights[weights.length - 1]`. The resolver iterates through the cumulative weights array, selecting the first variant index where the cumulative weight exceeds the generated random value.

```mermaid
flowchart TD
    A["Start resolveABTestURL"] --> B{"Valid testVariants & active testCompletedAt?"}
    B -- No --> C["Return null"]
    B -- Yes --> D{"Valid variant count bounds?"}
    D -- No --> E["Console error & return null"]
    D -- Yes --> F{"dub_test_url cookie matches variant URL?"}
    F -- Yes --> G["Return cookie URL"]
    F -- No --> H["Compute cumulative weights array"]
    H --> I["Generate random point within total weight"]
    I --> J["Find first weight > random value"]
    J --> K["Return selected variant URL"]
```

Sources: [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:16-58](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L16-L58)

## Edge Variant Selection

### Cookie Inspection and Destination Resolution

When link processing resolves a short URL in `LinkMiddleware`, it invokes `resolveABTestURL` using the cached link's `testVariants` and `testCompletedAt` properties. If a test is active, the resolver checks for a sticky session cookie named `dub_test_url` via Next.js headers cookies API. If the cookie exists and its URL value matches one of the candidate variant URLs, that exact destination is returned to maintain user session consistency. When no cookie is present, weighted random allocation computes a variant, and the selected URL is stored in cookie data parameters alongside tracking cookies.

Sources: [apps/web/lib/middleware/link.ts:168-173](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/link.ts#L168-L173), [apps/web/lib/middleware/link.ts:213-218](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/link.ts#L213-L218), [apps/web/lib/middleware/utils/resolve-ab-test-url.ts:8-36](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/utils/resolve-ab-test-url.ts#L8-L36)

### Middleware Integration

```typescript
const testUrl = await resolveABTestURL({
  testVariants,
  testCompletedAt,
});

const url = testUrl || cachedLink.url;

```

Sources: [apps/web/lib/middleware/link.ts:168-174](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/link.ts#L168-L174)

> [!NOTE]
> If `resolveABTestURL` returns a valid test URL, it overrides `cachedLink.url` as the primary destination for the incoming request, while `dubTestUrlValue` is propagated into the response cookie structure.

Sources: [apps/web/lib/middleware/link.ts:173](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/link.ts#L173), [apps/web/lib/middleware/link.ts:217](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/middleware/link.ts#L217)

## Automated Test Scheduling

### QStash Job Registration

```typescript
export async function scheduleABTestCompletion(
  link: Pick<ExpandedLink, "id" | "testVariants" | "testCompletedAt">,
) {
  if (!link.testVariants || !link.testCompletedAt) {
    return;
  }

  const url = `${APP_DOMAIN_WITH_NGROK}/api/cron/links/${link.id}/complete-tests`;
  const testCompletedAt = new Date(link.testCompletedAt);

  // Tests are not complete yet, schedule a job for completion
  if (testCompletedAt > new Date()) {
    await qstash.publishJSON({
      url,
      delay: (testCompletedAt.getTime() - new Date().getTime()) / 1000,
      deduplicationId: link.id,
    });
  }
}
```

Sources: [apps/web/lib/api/links/ab-test-scheduler.ts:6-24](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/links/ab-test-scheduler.ts#L6-L24)

The `scheduleABTestCompletion` function accepts a link object containing `id`, `testVariants`, and `testCompletedAt`. It verifies that both `testVariants` and `testCompletedAt` are defined on the link before proceeding; otherwise, it returns immediately without scheduling.

```
+------------------------------------+
| scheduleABTestCompletion(link)     |
+------------------------------------+
                  |
                  v
       { testVariants & testCompletedAt present? }
                  |
         +--------+--------+
         | Yes             | No
         v                 v
 { testCompletedAt > now? }  Return
         |
    +----+----+
    | Yes     | No
    v         v
 Publish JSON   Return
 via QStash
```

Sources: [apps/web/lib/api/links/ab-test-scheduler.ts:6-17](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/links/ab-test-scheduler.ts#L6-L17)

### Delay and Deduplication Configuration

When the expiration date stored in `link.testCompletedAt` is in the future compared to the current system time, the function constructs a target webhook URL using `APP_DOMAIN_WITH_NGROK` pointing to `/api/cron/links/${link.id}/complete-tests`. It invokes `qstash.publishJSON` with three parameters:

* `url`: The constructed cron endpoint destination URL.
* `delay`: The difference in seconds between `testCompletedAt` and the current timestamp, computed as `(testCompletedAt.getTime() - new Date().getTime()) / 1000`.
* `deduplicationId`: Set directly to `link.id` to prevent duplicate schedule entries for the same link test window.

> [!NOTE]
> The `deduplicationId` parameter ensures idempotent job registration through QStash, preventing multiple overlapping timers from being queued for a single link's A/B test completion event.

Sources: [apps/web/lib/api/links/ab-test-scheduler.ts:13-23](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/links/ab-test-scheduler.ts#L13-L23)

## Test Completion Trigger Endpoint

### Request Signature Verification and Link Lookup

The API route handler exposes a `POST` method at `/api/cron/links/[linkId]/complete-tests` that receives callbacks scheduled by QStash when an A/B test expiration window is reached. Upon receiving incoming request payloads, the endpoint reads the raw request text and validates authenticity by calling `verifyQstashSignature`. If verification fails, an error response is returned through `handleAndReturnErrorResponse`. After successful signature checks, the handler queries the database using Prisma to locate the link record matching the `linkId` dynamic route parameter. If no matching link record is found, the endpoint exits early and returns a plain text response indicating the link was not found and skipping further execution.

```typescript
try {
  const rawBody = await req.text();
  await verifyQstashSignature({ req, rawBody });

  const link = await prisma.link.findUnique({
    where: {
      id: linkId,
    },
  });

  if (!link) {
    return new Response(`Link ${linkId} not found. Skipping...`);
  }

```

Sources: [apps/web/app/ee/api/cron/links/linkId/complete-tests/route.ts:18-31](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/links/%5BlinkId%5D/complete-tests/route.ts#L18-L31)

### Test Window Validation and Completion Dispatch

Before triggering the test resolution flow, the endpoint evaluates three conditions to determine if the test is eligible for finalization. It confirms that `link.testVariants` and `link.testCompletedAt` are populated, verifies that the completion timestamp is in the past (`link.testCompletedAt < new Date()`), and ensures that the execution falls within a 15-minute window from the target completion time (`Date.now() - link.testCompletedAt.getTime() < 15 * 60 * 1000`). When all criteria are satisfied, the handler invokes `completeABTests(link)` and returns a confirmation string. Otherwise, it responds indicating no test completion is necessary.

```typescript
// only complete tests if:
// - there are test variants
// - the tests completion time is in the past
// - the tests completion time is within the last 15 minutes
if (
  link.testVariants &&
  link.testCompletedAt &&
  link.testCompletedAt < new Date() &&
  Date.now() - link.testCompletedAt.getTime() < 15 * 60 * 1000 // Limit to a 15-minute window
) {
  await completeABTests(link);

  return new Response(`Tests completed for link ${linkId}.`);
}

return new Response(`No test completion necessary for link ${linkId}.`);
```

Sources: [apps/web/app/ee/api/cron/links/linkId/complete-tests/route.ts:32-47](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/links/%5BlinkId%5D/complete-tests/route.ts#L32-L47)

> [!WARNING]
> The 15-minute execution window check (`Date.now() - link.testCompletedAt.getTime() < 15 * 60 * 1000`) acts as a safeguard against processing stale QStash callbacks that were delayed or retried long after their scheduled expiration time.

Sources: [apps/web/app/ee/api/cron/links/linkId/complete-tests/route.ts:35-41](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/cron/links/%5BlinkId%5D/complete-tests/route.ts#L35-L41)

## Analytics Evaluation and Winner Selection

### Tinybird Analytics Querying and Max Lead Calculation

The `completeABTests` function first verifies that `link.testVariants`, `link.testCompletedAt`, and `link.projectId` are all present; otherwise, it exits early. It extracts the variant configurations from `link.testVariants` and queries [Tinybird analytics](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-pipeline/tinybird-analytics) by invoking `getAnalytics` with the `leads` event, grouping by `top_base_urls`, and constraining the date range between `link.testStartedAt` (if available) and `link.testCompletedAt` for the given workspace and link ID. 

```typescript
export async function completeABTests(link: Link) {
  if (!link.testVariants || !link.testCompletedAt || !link.projectId) {
    return;
  }

  const testVariants = ABTestVariantsSchema.parse(link.testVariants);

  const analytics: { url: string; leads: number }[] = await getAnalytics({
    event: "leads",
    groupBy: "top_base_urls",
    linkId: link.id,
    workspaceId: link.projectId,
    start: link.testStartedAt ? new Date(link.testStartedAt) : undefined,
    end: link.testCompletedAt,
  });
```

Sources: [apps/web/lib/api/links/complete-ab-tests.ts:12-26](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/links/complete-ab-tests.ts#L12-L26)

The function calculates the maximum lead count obtained across all test variants by mapping each variant to its corresponding lead count from the analytics dataset and finding the peak value via `Math.max`. If the maximum lead count equals zero, no variant generated any leads during the test window, and the function logs a message and returns without modifying the link.

```typescript
const max = Math.max(
  ...testVariants.map(
    (test) => analytics.find(({ url }) => url === test.url)?.leads || 0,
  ),
);

// There are no leads generated for any test variant, do nothing
if (max === 0) {
  console.log(
    `AB Test completed but all results are zero for ${link.id}, doing nothing.`,
  );
  return;
}
```

Sources: [apps/web/lib/api/links/complete-ab-tests.ts:28-40](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/links/complete-ab-tests.ts#L28-L40)

### Winning Destination Selection and Tie-Breaking

Once a non-zero maximum lead count is established, the function filters all test variants to identify those whose lead count matches the maximum value. If the filtered winners array is empty, a fallback log check handles the edge case. When multiple variants achieve the exact same highest lead count, a single winner is selected randomly among them using `Math.floor(Math.random() * winners.length)`. If the chosen winner's URL matches the current `link.url`, no database update is required and execution terminates.

```typescript
const winners = testVariants.filter(
  (test) =>
    (analytics.find(({ url }) => url === test.url)?.leads || 0) === max,
);

// this should NEVER happen, but just in case
if (winners.length === 0) {
  console.log(
    `AB Test completed but failed to find winners based on max leads for link ${link.id}, doing nothing.`,
  );
  return;
}

const winner = winners[Math.floor(Math.random() * winners.length)];

if (winner.url === link.url) {
  return;
}
```

Sources: [apps/web/lib/api/links/complete-ab-tests.ts:42-59](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/links/complete-ab-tests.ts#L42-L59)

> [!NOTE]
> The random tie-breaker (`winners[Math.floor(Math.random() * winners.length)]`) ensures fair distribution when two or more variants record identical peak lead counts during the evaluation window.

Sources: [apps/web/lib/api/links/complete-ab-tests.ts:55-55](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/links/complete-ab-tests.ts#L55-L55)

### Link Rewriting and Background Operations

When a winning variant with a distinct URL is identified, the link record is updated in Prisma to set its primary destination URL to `winner.url`, including related tag, program enrollment, and project relations in the response. Using Vercel's `waitUntil`, the function asynchronously executes three cleanup and notification tasks via `Promise.allSettled`: updating the edge link cache via `linkCache.set(response)`, recording the updated link metadata in Tinybird via `recordLink(response)`, and dispatching a workspace webhook (`link.updated`) if the project relation exists.

```typescript

  const response = await prisma.link.update({
    where: {
      id: link.id,
    },
    data: {
      url: winner.url,
    },
    include: {
      ...includeTags,
      ...includeProgramEnrollment,
      project: true,
    },
  });

  waitUntil(
    Promise.allSettled([
      // update the link cache
      linkCache.set(response),
      // record the link
      recordLink(response),
      // send a link.updated webhook to the workspace
      response.project &&
        sendWorkspaceWebhook({
          trigger: "link.updated",
          workspace: response.project,
          data: linkEventSchema.parse(response),
        }),
    ]),
  );
}
```

Sources: [apps/web/lib/api/links/complete-ab-tests.ts:60-90](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/api/links/complete-ab-tests.ts#L60-L90)

## Related

- [Link Dispatch](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/link-attribution/link-dispatch)
- [Tinybird Analytics](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-pipeline/tinybird-analytics)


## Sitemap

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