---
title: "REST API"
description: "Dub exposes a comprehensive programmatic interface centered around a robust OpenAPI specification, designed to power integrations, developer tooling, and automated link attribution workflows. The R..."
last_updated: "2026-10-12T07:00:10.116617+00:00"
canonical_url: "https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/administration-and-api/rest-api"
---

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

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

- [apps/web/app/api/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/route.ts)
- [apps/web/lib/openapi/links/get-links.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/links/get-links.ts)
- [apps/web/lib/openapi/tags/get-tags.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/tags/get-tags.ts)
- [apps/web/lib/openapi/analytics/index.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/analytics/index.ts)
- [apps/web/app/api/oauth/userinfo/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/oauth/userinfo/route.ts)
- [apps/web/lib/openapi/customers/get-customers.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/customers/get-customers.ts)
- [apps/web/lib/openapi/folders/list-folders.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/folders/list-folders.ts)
- [apps/web/lib/openapi/links/get-link-info.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/links/get-link-info.ts)
- [apps/web/lib/openapi/qr/index.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/qr/index.ts)
- [apps/web/lib/auth/workspace.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts)
- [apps/web/lib/openapi/embed-tokens/index.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/embed-tokens/index.ts)
- [apps/web/lib/openapi/index.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/index.ts)
- [apps/web/lib/auth/publishable-key.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/publishable-key.ts)
- [apps/web/lib/openapi/domains/list-domains.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/domains/list-domains.ts)
- [apps/web/app/api/logs/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/logs/route.ts)
- [apps/web/lib/openapi/discount-codes/index.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/discount-codes/index.ts)
- [apps/web/app/ee/api/programs/programId/resources/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/programs/%5BprogramId%5D/resources/route.ts)
- [apps/web/app/ee/api/marketplace/programs/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/marketplace/programs/route.ts)
- [apps/web/lib/openapi/partners/retrieve-partner-links.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/partners/retrieve-partner-links.ts)
- [apps/web/app/ee/api/network/programs/summary/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/network/programs/summary/route.ts)
- [apps/web/lib/openapi/customers/index.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/customers/index.ts)
- [apps/web/lib/openapi/discount-codes/list-discount-codes.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/discount-codes/list-discount-codes.ts)
- [apps/web/lib/openapi/customers/get-customer.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/customers/get-customer.ts)
- [apps/web/lib/openapi/tags/index.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/tags/index.ts)
- [apps/web/app/api/integrations/route.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/integrations/route.ts)
- [apps/web/lib/openapi/links/index.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/links/index.ts)
- [apps/web/lib/openapi/links/get-links-count.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/links/get-links-count.ts)
- [apps/web/lib/openapi/partners/retrieve-analytics.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/partners/retrieve-analytics.ts)
- [apps/web/lib/openapi/events/index.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/events/index.ts)
- [apps/web/lib/openapi/program-applications/index.ts](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/program-applications/index.ts)
</details>

## Overview

Dub exposes a comprehensive programmatic interface centered around a robust OpenAPI specification, designed to power integrations, developer tooling, and automated link attribution workflows. The REST API is structured using Zod-backed schemas and Next.js route handlers that serve the complete OpenAPI definition at `/api` with long-term Vercel CDN caching. 

Sources: [apps/web/app/api/route.ts:1-13](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/route.ts#L1-L13), [apps/web/lib/openapi/index.ts:26-89](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/index.ts#L26-L89)

## OpenAPI Specification Generation

### Document Composition and Zod Integration

The public OpenAPI document is assembled using `createDocument` from the `zod-openapi` package, defining metadata for the Dub API. The document sets the OpenAPI version to `3.0.3` and includes metadata fields under `info`, such as title, description (`"Dub is the modern link attribution platform for short links, conversion tracking, and affiliate programs."`), version (`"0.0.1"`), contact information (`support@dub.co`), and the AGPL-3.0 license. The production server is configured with the base URL `https://api.dub.co`.

```typescript
export const document = createDocument({
  openapi: "3.0.3",
  info: {
    title: "Dub API",
    description:
      "Dub is the modern link attribution platform for short links, conversion tracking, and affiliate programs.",
    version: "0.0.1",
    contact: {
      name: "Dub Support",
      email: "support@dub.co",
      url: "https://dub.co/support",
    },
    license: {
      name: "AGPL-3.0 license",
      url: "https://github.com/dubinc/dub/blob/main/LICENSE.md",
    },
  },
  servers: [
    {
      url: "https://api.dub.co",
      description: "Production API",
    },
  ],
```
Sources: [apps/web/lib/openapi/index.ts:26-48](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/index.ts#L26-L48)

The schema paths aggregate individual path modules covering link operations, analytics, events, tags, folders, domains, tracking, customers, partners, program applications, discount codes, commissions, payouts, embed tokens, QR codes, and bounties.

| Path Module Variable | Description / Source Import |
| :--- | :--- |
| `linksPaths` | Link CRUD and management paths |
| `analyticsPath` | Analytics telemetry paths |
| `eventsPath` | Event stream query paths |
| `tagsPaths` | Link tag management paths |
| `foldersPaths` | Folder grouping paths |
| `domainsPaths` | Custom domain configuration paths |
| `trackPaths` | [Conversion tracking](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/analytics-pipeline/conversion-tracking) paths |
| `customersPaths` | Customer entity paths |
| `partnersPaths` | Partner program discovery paths |
| `programApplicationsPaths` | Affiliate application paths |
| `discountCodesPaths` | Discount code management paths |
| `commissionsPaths` | Commission tracking paths |
| `payoutsPaths` | Partner payout paths |
| `embedTokensPaths` | Embeddable dashboard token paths |
| `qrCodePaths` | [QR Code Generator](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/link-attribution/qr-code-generator) paths |
| `bountiesPaths` | Partner bounty paths |

```typescript
import { analyticsPath } from "./analytics";
import { bountiesPaths } from "./bounties";
import { commissionsPaths } from "./commissions";
import { customersPaths } from "./customers";
import { discountCodesPaths } from "./discount-codes";
import { domainsPaths } from "./domains";
import { embedTokensPaths } from "./embed-tokens";
import { eventsPath } from "./events";
import { foldersPaths } from "./folders";
import { linksPaths } from "./links";
import { partnersPaths } from "./partners";
import { payoutsPaths } from "./payouts";
import { programApplicationsPaths } from "./program-applications";
import { qrCodePaths } from "./qr";
import { openApiErrorResponsesComponents } from "./responses";
import { tagsPaths } from "./tags";
import { trackPaths } from "./track";
```
Sources: [apps/web/lib/openapi/index.ts:8-24](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/index.ts#L8-L24), [apps/web/lib/openapi/index.ts:49-66](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/index.ts#L49-L66)

Components registered within the OpenAPI specification define core data schemas, security schemes, and reusable error responses. The schema registry incorporates `LinkSchema`, `LinkTagSchema`, `FolderSchema`, `DomainSchema`, `DiscountCodeSchema`, `webhookEventSchema`, and `LinkErrorSchema`. Security is established via a bearer HTTP scheme named `token` with a Speakeasy example value of `DUB_API_KEY`. Reusable error responses are spread from `openApiErrorResponsesComponents`.

```typescript
components: {
  schemas: {
    LinkSchema,
    LinkTagSchema,
    FolderSchema,
    DomainSchema,
    DiscountCodeSchema,
    webhookEventSchema,
    LinkErrorSchema,
  },
  securitySchemes: {
    token: {
      type: "http",
      description: "Default authentication mechanism",
      scheme: "bearer",
      "x-speakeasy-example": "DUB_API_KEY",
    },
  },
  responses: {
    ...openApiErrorResponsesComponents,
  },
},
```
Sources: [apps/web/lib/openapi/index.ts:67-88](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/index.ts#L67-L88)

> [!NOTE]
> The OpenAPI route handler exports a `dynamic = "force-static"` configuration directive, indicating that the specification is statically generated at build time rather than dynamically computed on each individual request.
> Sources: [apps/web/app/api/route.ts:4-4](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/route.ts#L4-L4)

### Route Handler and Caching

The generated OpenAPI document object is served via a Next.js GET route handler located in `apps/web/app/api/route.ts`. The handler returns the document as a JSON response using `NextResponse.json(document)`, applying cache-control headers designed for long-term edge caching.

```typescript
export function GET() {
  return NextResponse.json(document, {
    headers: {
      // cache indefinitely till next deployment
      "Vercel-CDN-Cache-Control": "s-maxage=31536000",
      "Cache-Control": "public, max-age=31536000",
    },
  });
```
Sources: [apps/web/app/api/route.ts:6-13](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/route.ts#L6-L13)

The response explicitly assigns cache headers to cache the specification at the edge until the next deployment:
- `Vercel-CDN-Cache-Control`: `s-maxage=31536000`
- `Cache-Control`: `public, max-age=31536000`

Sources: [apps/web/app/api/route.ts:8-12](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/route.ts#L8-L12)

## Workspace Authentication and Authorization

### Request Authentication and Token Resolution

API requests targeting workspace resources are intercepted and authenticated by the `withWorkspace` wrapper middleware. The middleware inspects incoming HTTP requests for an `Authorization` header, enforcing that it carries the `Bearer ` prefix before extracting the raw credential. When an authorization header is present but lacks the expected prefix, it raises a `DubApiError` with the `bad_request` code. Requests containing an API key starting with the `dub_` prefix are classified as restricted tokens, whereas legacy or standard tokens are looked up against the database or verified via the local token cache using a hashed key representation.

```typescript
const authorizationHeader = requestHeaders.get("Authorization");
if (authorizationHeader) {
  if (!authorizationHeader.startsWith("Bearer ")) {
    throw new DubApiError({
      code: "bad_request",
      message:
        "Misconfigured authorization header. Did you forget to add 'Bearer '? Learn more: https://d.to/auth",
    });
  }
  apiKey = authorizationHeader.replace("Bearer ", "");
}

let workspaceId: string | undefined;
let workspaceSlug: string | undefined;
let permissions: PermissionAction[] = [];
const isRestrictedToken = apiKey?.startsWith("dub_");

```
Sources: [apps/web/lib/auth/workspace.ts:98-114](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L98-L114)

```typescript
if (apiKey) {
  const hashedKey = await hashToken(apiKey);

  const cachedToken = await tokenCache.get({
    hashedKey,
  });

  if (!cachedToken) {
    const prismaArgs = {
      where: {
        hashedKey,
      },
      select: {
        id: true,
        expires: true,
        ...(isRestrictedToken && {
          scopes: true,
          projectId: true,
          installationId: true,
          project: {
            select: {
              plan: true,
              trialEndsAt: true,
            },
          },
        }),
        user: true,
      },
    };

    if (isRestrictedToken) {
      token = await prisma.restrictedToken.findUnique(prismaArgs);
    } else {
      token = await prisma.token.findUnique(prismaArgs);
    }
  }
```
Sources: [apps/web/lib/auth/workspace.ts:168-203](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L168-L203)

If an API key is omitted, the middleware attempts to resolve an authenticated user session. Requests failing both token verification and session lookup trigger an `unauthorized` error response. Additionally, tokens that have passed their expiration timestamp are rejected with an access token expired message.

```typescript
if (!token || !token.user) {
  throw new DubApiError({
    code: "unauthorized",
    message: "Unauthorized: Invalid API key.",
  });
}

if (token.expires && token.expires < new Date()) {
  throw new DubApiError({
    code: "unauthorized",
    message: "Unauthorized: Access token expired.",
  });
}
```
Sources: [apps/web/lib/auth/workspace.ts:207-219](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L207-L219), [apps/web/lib/auth/workspace.ts:303-311](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L303-L311)

> [!WARNING]
> Omitting both an authorization header and a valid workspace identifier on non-anonymous routes will cause the middleware to throw either an `unauthorized` or `not_found` error, prompting clients to verify their `workspaceId` query parameters or upgrade from personal API keys.
> Sources: [apps/web/lib/auth/workspace.ts:127-154](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L127-L154)

### Workspace Resolution and Membership

Workspace targets are resolved from route parameters (`params.idOrSlug`, `params.slug`), query parameters (`searchParams.workspaceId`, `searchParams.projectSlug`), or directly from restricted token project associations (`token.projectId`). The identifier is inspected to determine whether it represents a workspace ID prefixed with `ws_` or a standard workspace slug.

```typescript
const idOrSlug =
  params?.idOrSlug ||
  searchParams.workspaceId ||
  params?.slug ||
  searchParams.projectSlug;

/*
  if there's no workspace ID or slug and it's not a restricted token:
  - special case for anonymous link creation
  - missing authorization header
  - user is still using personal API keys
*/
if (!idOrSlug && !isRestrictedToken) {
  // special case for anonymous link creation
  if (
    requestHeaders.has("dub-anonymous-link-creation") &&
    ["/links", "/api/links"].includes(req.nextUrl.pathname)
  ) {
    // @ts-expect-error
    return await handler({
      req: clonedReq,
      params,
      searchParams,
      headers: responseHeaders,
    });
    // missing authorization header
  } else if (!authorizationHeader) {
    throw new DubApiError({
      code: "unauthorized",
      message: "Missing Authorization header.",
    });
    // in case user is still using personal API keys
  } else {
    throw new DubApiError({
      code: "not_found",
      message:
        "Workspace ID not found. Did you forget to include a `workspaceId` query parameter? It looks like you might be using personal API keys, we also recommend refactoring to workspace API keys: https://d.to/keys",
    });
  }
}

if (idOrSlug) {
  if (idOrSlug.startsWith("ws_")) {
    workspaceId = normalizeWorkspaceId(idOrSlug);
  } else {
    workspaceSlug = idOrSlug;
  }
}
```
Sources: [apps/web/lib/auth/workspace.ts:115-162](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L115-L162), [apps/web/lib/auth/workspace.ts:261-263](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L261-L263)

Once the workspace record is queried, the middleware verifies whether the authenticated user belongs to the workspace by checking the associated user relation and role assignments. Endpoints like `GET /api/integrations` can specify explicit permission requirements (such as `workspaces.read`) through options passed to `withWorkspace`. If the target workspace does not exist, or if the user holds no active membership, the system evaluates pending project invites by email lookup to return precise error codes.

```typescript
export const GET = withWorkspace(
  async ({ workspace }) => {
    const integrations = await prisma.integration.findMany({
      where: {
        installations: {
          some: {
            project: {
              slug: workspace.slug,
            },
          },
        },
      },
      orderBy: {
        createdAt: "desc",
      },
    });

    return NextResponse.json(
      integrations.map((integration) =>
        installedIntegrationSchema.parse(integration),
      ),
    );
  },
  {
    requiredPermissions: ["workspaces.read"],
  },
```
Sources: [apps/web/app/api/integrations/route.ts:7-32](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/api/integrations/route.ts#L7-L32), [apps/web/lib/auth/workspace.ts:335-363](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L335-L363)

| Error Code | Trigger Condition |
| :--- | :--- |
| `not_found` | Workspace ID or slug does not match any existing project record |
| `invite_expired` | User is not a direct workspace member, but holds an expired project invite |
| `invite_pending` | User is not a direct workspace member, but holds a valid pending project invite |

```typescript
// workspace exists but user is not part of it
if (workspace.users.length === 0) {
  const pendingInvites = await prisma.projectInvite.findUnique({
    where: {
      email_projectId: {
        email: session.user.email,
        projectId: workspace.id,
      },
    },
    select: {
      expires: true,
    },
  });

  // Clear so non-member denials are not attributed to this workspace
  // in API logs or Axiom error context.
  workspace = undefined;

  if (!pendingInvites) {
    throw new DubApiError({
      code: "not_found",
      message: "Workspace not found.",
    });
  } else if (pendingInvites.expires < new Date()) {
    throw new DubApiError({
      code: "invite_expired",
      message: "Workspace invite expired.",
    });
  } else {
    throw new DubApiError({
      code: "invite_pending",
      message: "Workspace invite pending.",
    });
  }
}
```
Sources: [apps/web/lib/auth/workspace.ts:365-399](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L365-L399)

> [!NOTE]
> When a user lacks direct membership but holds a valid pending invitation, the middleware clears the partial workspace reference before throwing the `invite_pending` error to prevent misattributing access denials in Axiom logs.
> Sources: [apps/web/lib/auth/workspace.ts:379-398](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L379-L398)

### Rate Limiting and Telemetry

Rate limits are enforced on every authenticated request based on the caller's plan tier or active session. Analytics and event endpoints evaluate request limits over a 1-second interval (`1 s`), whereas standard API operations evaluate limits over a 1-minute interval (`1 m`). Plan limits are retrieved dynamically using `getRatelimitForPlan`, incorporating trial period boundaries where applicable.

```typescript
// Rate limit checks for API keys
let limit = 0;
let interval: `${number} s` | `${number} m` = isAnalytics
  ? "1 s"
  : "1 m";

const planLimit = getRatelimitForPlan(token.project?.plan || "free", {
  trialEndsAt: token.project?.trialEndsAt ?? null,
});
limit = planLimit.limits[isAnalytics ? "analyticsApi" : "api"];

const { success, headers } = await rateLimitRequest({
  identifier: `workspace:ratelimit:${hashedKey}`,
  requests: limit,
  interval,
});
```
Sources: [apps/web/lib/auth/workspace.ts:230-245](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L230-L245), [apps/web/lib/auth/workspace.ts:313-322](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L313-L322)

When rate limits are exhausted, `rateLimitRequest` returns an unsuccessful status, causing the middleware to throw a `rate_limit_exceeded` error. Successful requests propagate rate limit metadata headers back to the client and asynchronously record token last-used timestamps throttled to once per minute.

```typescript
const { success, headers } = await rateLimitRequest({
  identifier: `workspace:ratelimit:${hashedKey}`,
  requests: limit,
  interval,
});

if (headers) {
  for (const [key, value] of Object.entries(headers)) {
    responseHeaders.set(key, value);
  }
}

if (!success) {
  throw new DubApiError({
    code: "rate_limit_exceeded",
    message: "Too many requests.",
  });
}

// Find workspaceId if it's a restricted token
if (isRestrictedToken && token?.projectId) {
  workspaceId = token.projectId;
}

waitUntil(
  // update last used time for the token (only once every minute)
  (async () => {
    try {
      const { success } = await ratelimit(1, "1 m").limit(
        `last-used-${hashedKey}`,
      );

      if (success) {
        const prismaArgs = {
          where: {
            hashedKey,
          },
          data: {
            lastUsed: new Date(),
          },
        };

        if (isRestrictedToken) {
          await prisma.restrictedToken.update(prismaArgs);
        } else {
          await prisma.token.update(prismaArgs);
        }
      }
    } catch (error) {
      console.error(error);
    }
  })(),
);
```
Sources: [apps/web/lib/auth/workspace.ts:241-293](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L241-L293), [apps/web/lib/auth/workspace.ts:323-332](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/workspace.ts#L323-L332)

## Client Publishable Key Authentication

### Publishable Key Validation and Request Flow

Public client routes authenticate incoming requests using the `withPublishableKey` wrapper function. The middleware extracts the `Authorization` header from the request, verifying that it starts with the `Bearer ` prefix and contains a token beginning with the required `dub_pk_` prefix. If either validation check fails, the handler throws a `DubApiError` with the `bad_request` code and a message indicating an invalid or missing publishable key.

```typescript
const authorizationHeader = requestHeaders.get("Authorization");
if (authorizationHeader) {
  if (!authorizationHeader.startsWith("Bearer ")) {
    throw new DubApiError({
      code: "bad_request",
      message: "Invalid or missing publishable key.",
    });
  }

  const publishableKey = authorizationHeader.replace("Bearer ", "");
  if (!publishableKey.startsWith("dub_pk_")) {
    throw new DubApiError({
      code: "bad_request",
      message: "Invalid or missing publishable key.",
    });
  }
```
Sources: [apps/web/lib/auth/publishable-key.ts:45-60](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/publishable-key.ts#L45-L60)

Once the key format is verified, the system enforces rate limiting using Upstash with a limit of 600 requests per minute (`1 m`) keyed directly by the publishable key string. Rate limit metadata headers (`Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`) are injected into the response headers. If the rate limit is exceeded, the request is rejected with a `rate_limit_exceeded` error code.

```typescript
const { success, limit, reset, remaining } = await ratelimit(
  600,
  "1 m",
).limit(publishableKey);

responseHeaders.set("Retry-After", reset.toString());
responseHeaders.set("X-RateLimit-Limit", limit.toString());
responseHeaders.set("X-RateLimit-Remaining", remaining.toString());
responseHeaders.set("X-RateLimit-Reset", reset.toString());

if (!success) {
  throw new DubApiError({
    code: "rate_limit_exceeded",
    message: "Too many requests.",
  });
}
```
Sources: [apps/web/lib/auth/publishable-key.ts:62-77](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/publishable-key.ts#L62-L77)

After rate limit evaluation, the workspace record is retrieved from the database by querying the `publishableKey` field. If no matching project is found, an `unauthorized` error is thrown. The workspace plan is normalized for business tiers before checking against the `requiredPlan` configuration.

```typescript
workspace = await prisma.project.findUnique({
  where: {
    publishableKey,
  },
});

if (!workspace) {
  throw new DubApiError({
    code: "unauthorized",
    message: "Invalid publishable key.",
  });
}

const normalizedWorkspacePlan = [
  "business plus",
  "business max",
  "business extra",
].includes(workspace.plan)
  ? "business"
  : workspace.plan;

if (!requiredPlan.includes(normalizedWorkspacePlan)) {
  throw new DubApiError({
    code: "forbidden",
    message: "Unauthorized: Need higher plan.",
  });
}
```
Sources: [apps/web/lib/auth/publishable-key.ts:79-105](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/publishable-key.ts#L79-L105)

> [!NOTE]
> Workspace plans matching `business plus`, `business max`, or `business extra` are automatically normalized to `business` before validating against the endpoint's `requiredPlan` array.
> Sources: [apps/web/lib/auth/publishable-key.ts:92-99](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/publishable-key.ts#L92-L99)

### Error Handling and Telemetry Capture

When request handlers execute successfully or encounter errors, the middleware records request logs asynchronously using Vercel's `waitUntil` combined with `captureRequestLog` and Axiom integration.

```typescript
waitUntil(
  captureRequestLog({
    req: reqForLog,
    response,
    workspace,
    session: undefined,
    token: null,
    url,
    requestHeaders,
    startTime,
  }),
);
```
Sources: [apps/web/lib/auth/publishable-key.ts:115-126](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/publishable-key.ts#L115-L126)

| Error Code | Trigger Condition |
| :--- | :--- |
| `bad_request` | Authorization header is missing, does not start with `Bearer `, or the token does not start with `dub_pk_` |
| `rate_limit_exceeded` | Request volume exceeds 600 requests per minute for the given publishable key |
| `unauthorized` | Authorization header is completely absent, or no workspace matches the provided publishable key |
| `forbidden` | The normalized workspace plan is not included in the endpoint's `requiredPlan` array |

```typescript
const authorizationHeader = requestHeaders.get("Authorization");
if (authorizationHeader) {
  if (!authorizationHeader.startsWith("Bearer ")) {
    throw new DubApiError({
      code: "bad_request",
      message: "Invalid or missing publishable key.",
    });
  }

  const publishableKey = authorizationHeader.replace("Bearer ", "");
  if (!publishableKey.startsWith("dub_pk_")) {
    throw new DubApiError({
      code: "bad_request",
      message: "Invalid or missing publishable key.",
    });
  }

  const { success, limit, reset, remaining } = await ratelimit(
    600,
    "1 m",
  ).limit(publishableKey);

  responseHeaders.set("Retry-After", reset.toString());
  responseHeaders.set("X-RateLimit-Limit", limit.toString());
  responseHeaders.set("X-RateLimit-Remaining", remaining.toString());
  responseHeaders.set("X-RateLimit-Reset", reset.toString());

  if (!success) {
    throw new DubApiError({
      code: "rate_limit_exceeded",
      message: "Too many requests.",
    });
  }

  workspace = await prisma.project.findUnique({
    where: {
      publishableKey,
    },
  });

  if (!workspace) {
    throw new DubApiError({
      code: "unauthorized",
      message: "Invalid publishable key.",
    });
  }

  const normalizedWorkspacePlan = [
    "business plus",
    "business max",
    "business extra",
  ].includes(workspace.plan)
    ? "business"
    : workspace.plan;

  if (!requiredPlan.includes(normalizedWorkspacePlan)) {
    throw new DubApiError({
      code: "forbidden",
      message: "Unauthorized: Need higher plan.",
    });
  }
```
Sources: [apps/web/lib/auth/publishable-key.ts:45-105](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/publishable-key.ts#L45-L105), [apps/web/lib/auth/publishable-key.ts:130-134](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/publishable-key.ts#L130-L134)

> [!WARNING]
> If an exception occurs during request processing after a workspace has been successfully resolved, the error response is still captured in the telemetry logs alongside the valid workspace reference.
> Sources: [apps/web/lib/auth/publishable-key.ts:135-154](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/auth/publishable-key.ts#L135-L154)

## Link Operations and Schemas

### Route Definitions and Query Operations

Link routing operations are structured under the `/links` path tree using `ZodOpenApiPathsObject` definitions. The resource map covers individual link mutations, bulk procedures, and counting endpoints alongside standard query retrieval.

```typescript
export const linksPaths: ZodOpenApiPathsObject = {
  "/links": {
    post: createLink,
    get: getLinks,
  },
  "/links/count": {
    get: getLinksCount,
  },
  "/links/info": {
    get: getLinkInfo,
  },
  "/links/{linkId}": {
    patch: updateLink,
    delete: deleteLink,
  },
  "/links/bulk": {
    post: bulkCreateLinks,
    patch: bulkUpdateLinks,
    delete: bulkDeleteLinks,
  },
  "/links/upsert": {
    put: upsertLink,
  },
};
```
Sources: [apps/web/lib/openapi/links/index.ts:13-36](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/links/index.ts#L13-L36)

The `getLinks` operation defines an OpenAPI object with `operationId` set to `getLinks`, a Speakeasy name override of `list`, and offset-limit pagination mapping parameters `page` and `pageSize` to input parameters while outputting results directly from root. It validates incoming request query parameters using `getLinksQuerySchemaBase` and responds with a 200 array of `LinkSchema` items.

```typescript
export const getLinks: ZodOpenApiOperationObject = {
  operationId: "getLinks",
  "x-speakeasy-name-override": "list",
  "x-speakeasy-pagination": {
    type: "offsetLimit",
    inputs: [
      {
        name: "page",
        in: "parameters",
        type: "page",
      },
      {
        name: "pageSize",
        in: "parameters",
        type: "limit",
      },
    ],
    outputs: {
      results: "$",
    },
  },
  summary: "List all links",
  description:
    "Retrieve a paginated list of links for the authenticated workspace.",
  requestParams: {
    query: getLinksQuerySchemaBase,
  },
  responses: {
    "200": {
      description: "A list of links",
      content: {
        "application/json": {
          schema: z.array(LinkSchema),
        },
      },
    },
    ...openApiErrorResponses,
  },
  tags: ["Links"],
  security: [{ token: [] }],
};
```
Sources: [apps/web/lib/openapi/links/get-links.ts:6-46](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/links/get-links.ts#L6-L46)

Metadata retrieval for single links is handled by `getLinkInfo`, exposing the `getLinkInfo` operation ID and `get` name override. It accepts query parameters validated via `getLinkInfoQuerySchema` and returns a single `LinkSchema` object under a 200 status code response.

```typescript
export const getLinkInfo: ZodOpenApiOperationObject = {
  operationId: "getLinkInfo",
  "x-speakeasy-name-override": "get",
  summary: "Retrieve a link",
  description: "Retrieve the info for a link.",
  requestParams: {
    query: getLinkInfoQuerySchema,
  },
  responses: {
    "200": {
      description: "The retrieved link",
      content: {
        "application/json": {
          schema: LinkSchema,
        },
      },
    },
    ...openApiErrorResponses,
  },
  tags: ["Links"],
  security: [{ token: [] }],
};
```
Sources: [apps/web/lib/openapi/links/get-link-info.ts:5-26](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/links/get-link-info.ts#L5-L26)

Count aggregation is handled through the `getLinksCount` operation defined at `/links/count`. It utilizes `getLinksCountQuerySchema` for query validation and returns a numerical Zod schema carrying metadata describing the matching record count.

```typescript
export const getLinksCount: ZodOpenApiOperationObject = {
  operationId: "getLinksCount",
  "x-speakeasy-name-override": "count",
  summary: "Retrieve links count",
  description: "Retrieve the number of links for the authenticated workspace.",
  requestParams: {
    query: getLinksCountQuerySchema,
  },
  responses: {
    "200": {
      description: "A list of links",
      content: {
        "application/json": {
          schema: z.number().meta({
            description: "The number of links matching the query.",
          }),
        },
      },
    },
    ...openApiErrorResponses,
  },
  tags: ["Links"],
  security: [{ token: [] }],
};
```
Sources: [apps/web/lib/openapi/links/get-links-count.ts:6-29](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/links/get-links-count.ts#L6-L29)

| Operation ID | Method & Path | Speakeasy Override | Request Query Schema | Response Schema |
| :--- | :--- | :--- | :--- | :--- |
| `getLinks` | `GET /links` | `list` | `getLinksQuerySchemaBase` | `z.array(LinkSchema)` |
| `getLinkInfo` | `GET /links/info` | `get` | `getLinkInfoQuerySchema` | `LinkSchema` |
| `getLinksCount` | `GET /links/count` | `count` | `getLinksCountQuerySchema` | `z.number()` |

```typescript
"/links/count": {
  get: getLinksCount,
},
"/links/info": {
  get: getLinkInfo,
},
```
Sources: [apps/web/lib/openapi/links/index.ts:18-23](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/links/index.ts#L18-L23), [apps/web/lib/openapi/links/get-links.ts:30-41](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/links/get-links.ts#L30-L41), [apps/web/lib/openapi/links/get-link-info.ts:10-21](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/links/get-link-info.ts#L10-L21), [apps/web/lib/openapi/links/get-links-count.ts:11-24](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/links/get-links-count.ts#L11-L24)

> [!NOTE]
> All link query operations require token security credentials and inherit standard OpenAPI error responses via `openApiErrorResponses`.
> Sources: [apps/web/lib/openapi/links/get-links.ts:41-45](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/links/get-links.ts#L41-L45), [apps/web/lib/openapi/links/get-link-info.ts:22-25](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/links/get-link-info.ts#L22-L25), [apps/web/lib/openapi/links/get-links-count.ts:25-28](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/links/get-links-count.ts#L25-L28)

## Analytics and Event Telemetry

### Analytics Endpoint and Response Schema

The analytics API is mounted under the `/analytics` route path and handles retrieval for links, domains, or workspaces based on query configuration. The `retrieveAnalytics` operation defines its query parameters using `analyticsQuerySchema` and uses a Speakeasy name override of `retrieve`.

```typescript
const retrieveAnalytics: ZodOpenApiOperationObject = {
  operationId: "retrieveAnalytics",
  "x-speakeasy-name-override": "retrieve",
  summary:
    "Retrieve analytics for a link, a domain, or the authenticated workspace.",
  description:
    "Retrieve analytics for a link, a domain, or the authenticated workspace. The response type depends on the `event` and `type` query parameters.",
  requestParams: {
    query: analyticsQuerySchema,
  },
```
Sources: [apps/web/lib/openapi/analytics/index.ts:7-16](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/analytics/index.ts#L7-L16)

The response schema for `retrieveAnalytics` accepts a union of types depending on the requested dimension, validating outputs against specific metric definitions. Success responses return application JSON formatted as either an analytics count or an array of dimension objects.

| OpenAPI ID | Zod Schema Binding | Target Metric Dimension |
| :--- | :--- | :--- |
| `AnalyticsCount` | `analyticsResponse.count` | Aggregate count metrics |
| `AnalyticsTimeseries` | `analyticsResponse.timeseries` | Timeseries datapoints |
| `AnalyticsContinents` | `analyticsResponse.continents` | Continents breakdown |
| `AnalyticsCountries` | `analyticsResponse.countries` | Countries breakdown |
| `AnalyticsRegions` | `analyticsResponse.regions` | Regions breakdown |
| `AnalyticsCities` | `analyticsResponse.cities` | Cities breakdown |
| `AnalyticsDevices` | `analyticsResponse.devices` | Device types |
| `AnalyticsBrowsers` | `analyticsResponse.browsers` | Browsers breakdown |
| `AnalyticsOS` | `analyticsResponse.os` | Operating systems |
| `AnalyticsTriggers` | `analyticsResponse.triggers` | Click triggers |
| `AnalyticsEventNames` | `analyticsResponse.event_names` | Event names |
| `AnalyticsReferers` | `analyticsResponse.referers` | Referers breakdown |
| `AnalyticsRefererUrls` | `analyticsResponse.referer_urls` | Referer URLs |
| `AnalyticsTopLinks` | `analyticsResponse.top_links` | Top performing links |
| `AnalyticsTopUrls` | `analyticsResponse.top_urls` | Top target URLs |

```typescript
"200": {
  description: "Analytics data",
  content: {
    "application/json": {
      schema: z.union([
        analyticsResponse.count.meta({
          id: "AnalyticsCount",
        }),
        z.array(
          analyticsResponse.timeseries.meta({
            id: "AnalyticsTimeseries",
          }),
        ),
        z.array(
          analyticsResponse.continents.meta({
            id: "AnalyticsContinents",
          }),
        ),
        z.array(
          analyticsResponse.countries.meta({
            id: "AnalyticsCountries",
          }),
        ),
        z.array(
          analyticsResponse.regions.meta({
            id: "AnalyticsRegions",
          }),
        ),
        z.array(
          analyticsResponse.cities.meta({
            id: "AnalyticsCities",
          }),
        ),
        z.array(
          analyticsResponse.devices.meta({
            id: "AnalyticsDevices",
          }),
        ),
        z.array(
          analyticsResponse.browsers.meta({
            id: "AnalyticsBrowsers",
          }),
        ),
        z.array(
          analyticsResponse.os.meta({
            id: "AnalyticsOS",
          }),
        ),
        z.array(
          analyticsResponse.triggers.meta({
            id: "AnalyticsTriggers",
          }),
        ),
        z.array(
          analyticsResponse.event_names.meta({
            id: "AnalyticsEventNames",
          }),
        ),
        z.array(
          analyticsResponse.referers.meta({
            id: "AnalyticsReferers",
          }),
        ),
        z.array(
          analyticsResponse.referer_urls.meta({
            id: "AnalyticsRefererUrls",
          }),
        ),
        z.array(
          analyticsResponse.top_links.meta({
            id: "AnalyticsTopLinks",
          }),
        ),
        z.array(
          analyticsResponse.top_urls.meta({
            id: "AnalyticsTopUrls",
          }),
        ),
      ]),
    },
```
Sources: [apps/web/lib/openapi/analytics/index.ts:18-97](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/analytics/index.ts#L18-L97)

> [!NOTE]
> The `retrieveAnalytics` operation is tagged under `Analytics`, requires bearer token security credentials via `[{ token: [] }]`, and inherits standard OpenAPI error responses.
> Sources: [apps/web/lib/openapi/analytics/index.ts:100-104](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/analytics/index.ts#L100-L104)

### Raw Event Streaming and Schemas

Event telemetry retrieval is exposed via the `/events` endpoint through the `listEvents` operation. It accepts paginated query parameters defined by `eventsQuerySchema` and returns a list of telemetry events for the authenticated workspace.

```typescript
export const listEvents: ZodOpenApiOperationObject = {
  operationId: "listEvents",
  "x-speakeasy-name-override": "list",
  summary: "List all events",
  description:
    "Retrieve a paginated list of events for the authenticated workspace.",
  requestParams: {
    query: eventsQuerySchema,
  },
```
Sources: [apps/web/lib/openapi/events/index.ts:9-17](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/events/index.ts#L9-L17)

The response content for `listEvents` validates items against a discriminated union keyed on the `event` property. This union supports click, lead, and sale event response schemas.

```typescript
responses: {
  "200": {
    description: "A list of events",
    content: {
      "application/json": {
        schema: z.array(
          z.discriminatedUnion("event", [
            clickEventResponseSchema,
            leadEventResponseSchema,
            saleEventResponseSchema,
          ]),
        ),
      },
    },
  },
```
Sources: [apps/web/lib/openapi/events/index.ts:18-32](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/events/index.ts#L18-L32)

| Schema Constant | Response Type | Discriminator Value |
| :--- | :--- | :--- |
| `clickEventResponseSchema` | Click telemetry event | `click` |
| `leadEventResponseSchema` | Lead conversion event | `lead` |
| `saleEventResponseSchema` | Sale conversion event | `sale` |

```typescript
schema: z.array(
  z.discriminatedUnion("event", [
    clickEventResponseSchema,
    leadEventResponseSchema,
    saleEventResponseSchema,
  ]),
```
Sources: [apps/web/lib/openapi/events/index.ts:23-28](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/events/index.ts#L23-L28)

> [!WARNING]
> Omitting the discriminator `event` parameter during event payload consumption will cause Zod validation failures against the discriminated union schema.
> Sources: [apps/web/lib/openapi/events/index.ts:23-28](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/events/index.ts#L23-L28)

## Domain and Organization Resource Management

### Workspace Domain and Entity Query Operations

The OpenAPI specification defines resource endpoints to query and manage organization-level assets including domains, tags, and folders. The `listDomains` operation is exposed under the `Domains` tag with operation ID `listDomains` and utilizes an offset-limit pagination schema.

```typescript
export const listDomains: ZodOpenApiOperationObject = {
  operationId: "listDomains",
  "x-speakeasy-name-override": "list",
  "x-speakeasy-pagination": {
    type: "offsetLimit",
    inputs: [
      {
        name: "page",
        in: "parameters",
        type: "page",
      },
      {
        name: "pageSize",
        in: "parameters",
        type: "limit",
      },
    ],
    outputs: {
      results: "$",
    },
  },
```
Sources: [apps/web/lib/openapi/domains/list-domains.ts:6-26](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/domains/list-domains.ts#L6-L26)

Query inputs accept `page` and `pageSize` parameters mapped to parameters input type, returning an array of `DomainSchema` objects with standard error responses and token security requirements.

```typescript
summary: "List all domains",
description:
  "Retrieve a paginated list of domains for the authenticated workspace.",
requestParams: {
  query: getDomainsQuerySchema,
},
responses: {
  "200": {
    description: "The domains were retrieved.",
    content: {
      "application/json": {
        schema: z.array(DomainSchema),
      },
    },
  },
  ...openApiErrorResponses,
},
tags: ["Domains"],
security: [{ token: [] }],
```
Sources: [apps/web/lib/openapi/domains/list-domains.ts:27-45](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/domains/list-domains.ts#L27-L45)

> [!NOTE]
> The Speakeasy configuration for `listDomains` defines pagination outputs using the root path output `$`.
> Sources: [apps/web/lib/openapi/domains/list-domains.ts:23-26](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/domains/list-domains.ts#L23-L26)

### Tags and Folders Management Schemas

Organization categorization assets are handled through dedicated resource definitions. The `getTags` operation retrieves paginated tags for the authenticated workspace using `getTagsQuerySchema` and validates response items against `LinkTagSchema`.

```typescript
export const getTags: ZodOpenApiOperationObject = {
  operationId: "getTags",
  "x-speakeasy-name-override": "list",
  summary: "List all tags",
  description:
    "Retrieve a paginated list of tags for the authenticated workspace.",
  requestParams: {
    query: getTagsQuerySchema,
  },
  responses: {
    "200": {
      description: "A list of tags",
      content: {
        "application/json": {
          schema: z.array(LinkTagSchema),
        },
      },
    },
    ...openApiErrorResponses,
  },
  tags: ["Tags"],
  security: [{ token: [] }],
```
Sources: [apps/web/lib/openapi/tags/get-tags.ts:6-27](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/tags/get-tags.ts#L6-L27)

Tag mutation paths are grouped under `/tags` and `/tags/{id}` routes, combining creation, listing, updating, and deletion behaviors.

```typescript
export const tagsPaths: ZodOpenApiPathsObject = {
  "/tags": {
    post: createTag,
    get: getTags,
  },
  "/tags/{id}": {
    patch: updateTag,
    delete: deleteTag,
  },
};
```
Sources: [apps/web/lib/openapi/tags/index.ts:8-17](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/tags/index.ts#L8-L17)

Similarly, folder retrieval is exposed via the `listFolders` operation under the `Folders` tag, validating query parameters with `listFoldersQuerySchema` and returning arrays matching `FolderSchema`.

```typescript
export const listFolders: ZodOpenApiOperationObject = {
  operationId: "listFolders",
  "x-speakeasy-name-override": "list",
  summary: "List all folders",
  description:
    "Retrieve a paginated list of folders for the authenticated workspace.",
  requestParams: {
    query: listFoldersQuerySchema,
  },
  responses: {
    "200": {
      description: "A list of folders",
      content: {
        "application/json": {
          schema: z.array(FolderSchema),
        },
      },
    },
    ...openApiErrorResponses,
  },
  tags: ["Folders"],
  security: [{ token: [] }],
```
Sources: [apps/web/lib/openapi/folders/list-folders.ts:9-30](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/folders/list-folders.ts#L9-L30)

| Operation ID | HTTP Method & Path | Query Schema Binding | Response Schema | Tag |
| :--- | :--- | :--- | :--- | :--- |
| `listDomains` | `GET /domains` | `getDomainsQuerySchema` | `z.array(DomainSchema)` | `Domains` |
| `getTags` | `GET /tags` | `getTagsQuerySchema` | `z.array(LinkTagSchema)` | `Tags` |
| `listFolders` | `GET /folders` | `listFoldersQuerySchema` | `z.array(FolderSchema)` | `Folders` |

```typescript
requestParams: {
  query: getDomainsQuerySchema,
},
responses: {
  "200": {
    description: "The domains were retrieved.",
    content: {
      "application/json": {
        schema: z.array(DomainSchema),
      },
    },
  },
…
requestParams: {
  query: getTagsQuerySchema,
},
responses: {
  "200": {
    description: "A list of tags",
    content: {
      "application/json": {
        schema: z.array(LinkTagSchema),
      },
…
requestParams: {
  query: listFoldersQuerySchema,
},
responses: {
  "200": {
    description: "A list of folders",
    content: {
      "application/json": {
        schema: z.array(FolderSchema),
      },
```
Sources: [apps/web/lib/openapi/domains/list-domains.ts:30-41](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/domains/list-domains.ts#L30-L41), [apps/web/lib/openapi/tags/get-tags.ts:12-21](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/tags/get-tags.ts#L12-L21), [apps/web/lib/openapi/folders/list-folders.ts:15-24](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/folders/list-folders.ts#L15-L24)

> [!WARNING]
> All resource listing endpoints require bearer token workspace authorization (`security: [{ token: [] }]`) and will return OpenAPI error responses if credentials are omitted or invalid.
> Sources: [apps/web/lib/openapi/domains/list-domains.ts:42-45](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/domains/list-domains.ts#L42-L45), [apps/web/lib/openapi/tags/get-tags.ts:24-27](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/tags/get-tags.ts#L24-L27), [apps/web/lib/openapi/folders/list-folders.ts:27-30](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/folders/list-folders.ts#L27-L30)

## Partner and Commerce Resource Endpoints

### Customer Entity Endpoints

Customer management operations are exposed via paths under `/customers` and `/customers/{id}`. The `getCustomers` operation retrieves a paginated list of customers for the authenticated workspace, utilizing cursor pagination via Speakeasy annotations with the `startingAfter` cursor input parameter and returning an array of `CustomerEnrichedSchema` objects.

```typescript
export const getCustomers: ZodOpenApiOperationObject = {
  operationId: "getCustomers",
  "x-speakeasy-name-override": "list",
  "x-speakeasy-pagination": {
    type: "cursor",
    inputs: [
      {
        name: "startingAfter",
        in: "parameters",
        type: "cursor",
      },
    ],
    outputs: {
      nextCursor: "$[-1].id",
    },
  },
  summary: "List all customers",
  description:
    "Retrieve a paginated list of customers for the authenticated workspace.",
  requestParams: {
    query: getCustomersQuerySchema,
  },
  responses: {
    "200": {
      description: "The list of customers.",
      content: {
        "application/json": {
          schema: z.array(CustomerEnrichedSchema),
        },
      },
    },
    ...openApiErrorResponses,
  },
  tags: ["Customers"],
  security: [{ token: [] }],
};
```
Sources: [apps/web/lib/openapi/customers/get-customers.ts:9-44](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/customers/get-customers.ts#L9-L44)

Individual customer retrieval is handled by `getCustomer`, which maps path parameters to `CustomerEnrichedSchema.pick({ id: true })` and query parameters to expanded fields selection. To retrieve a customer by an external ID, callers must prefix the identifier value with `ext_`.

```typescript
export const getCustomer: ZodOpenApiOperationObject = {
  operationId: "getCustomer",
  "x-speakeasy-name-override": "get",
  summary: "Retrieve a customer",
  description:
    "Retrieve a customer by ID for the authenticated workspace. To retrieve a customer by external ID, prefix the ID with `ext_`.",
  requestParams: {
    path: CustomerEnrichedSchema.pick({ id: true }),
    query: getCustomersQuerySchema.pick({ includeExpandedFields: true }),
  },
  responses: {
    "200": {
      description: "The customer object.",
      content: {
        "application/json": {
          schema: CustomerEnrichedSchema,
        },
      },
    },
    ...openApiErrorResponses,
  },
  tags: ["Customers"],
  security: [{ token: [] }],
};
```
Sources: [apps/web/lib/openapi/customers/get-customer.ts:8-31](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/customers/get-customer.ts#L8-L31)

> [!NOTE]
> The `getCustomer` operation overrides Speakeasy's method naming to `get` while `getCustomers` maps to `list`, supporting distinct SDK client generation names across the `Customers` tag group.
> Sources: [apps/web/lib/openapi/customers/get-customers.ts:10-11](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/customers/get-customers.ts#L10-L11), [apps/web/lib/openapi/customers/get-customer.ts:9-10](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/customers/get-customer.ts#L9-L10)

### Discount Code Schemas and Paths

Discount codes are defined under `/discount-codes` and `/discount-codes/{idOrCode}` endpoints. The `listDiscountCodes` operation permits querying discount codes within a program or filtered by specific attributes using `getDiscountCodesQuerySchema`, returning an array matching `DiscountCodeSchema`.

```typescript
export const listDiscountCodes: ZodOpenApiOperationObject = {
  operationId: "listDiscountCodes",
  "x-speakeasy-name-override": "list",
  summary: "List discount codes",
  description:
    "Retrieve a paginated list of discount codes in a program or filtered by partner, discount, or code.",
  requestParams: {
    query: getDiscountCodesQuerySchema,
  },
  responses: {
    "200": {
      description: "The list of discount codes.",
      content: {
        "application/json": {
          schema: z.array(DiscountCodeSchema),
        },
      },
    },
    ...openApiErrorResponses,
  },
  tags: ["Discount Codes"],
  security: [{ token: [] }],
};
```
Sources: [apps/web/lib/openapi/discount-codes/list-discount-codes.ts:9-31](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/discount-codes/list-discount-codes.ts#L9-L31)

The discount code router aggregates listing, creation, and deletion routes into a single `discountCodesPaths` object configuration.

```typescript
export const discountCodesPaths: ZodOpenApiPathsObject = {
  "/discount-codes": {
    get: listDiscountCodes,
    post: createDiscountCode,
  },
  "/discount-codes/{idOrCode}": {
    delete: deleteDiscountCode,
  },
};
```
Sources: [apps/web/lib/openapi/discount-codes/index.ts:6-14](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/discount-codes/index.ts#L6-L14)

| Operation ID | HTTP Method & Path | Primary Schema | Tag |
| :--- | :--- | :--- | :--- |
| `getCustomers` | `GET /customers` | `CustomerEnrichedSchema` | `Customers` |
| `getCustomer` | `GET /customers/{id}` | `CustomerEnrichedSchema` | `Customers` |
| `listDiscountCodes` | `GET /discount-codes` | `DiscountCodeSchema` | `Discount Codes` |
| `retrievePartnerLinks` | `GET /partners/links` | `ProgramPartnerLinkSchema` | `Partners` |

```typescript
summary: "List all customers",
description:
  "Retrieve a paginated list of customers for the authenticated workspace.",
requestParams: {
  query: getCustomersQuerySchema,
},
responses: {
  "200": {
    description: "The list of customers.",
    content: {
      "application/json": {
        schema: z.array(CustomerEnrichedSchema),
      },
    },
  },
  ...openApiErrorResponses,
},
tags: ["Customers"],
…
summary: "Retrieve a customer",
description:
  "Retrieve a customer by ID for the authenticated workspace. To retrieve a customer by external ID, prefix the ID with `ext_`.",
requestParams: {
  path: CustomerEnrichedSchema.pick({ id: true }),
  query: getCustomersQuerySchema.pick({ includeExpandedFields: true }),
},
responses: {
  "200": {
    description: "The customer object.",
    content: {
      "application/json": {
        schema: CustomerEnrichedSchema,
      },
    },
  },
  ...openApiErrorResponses,
},
tags: ["Customers"],
…
summary: "List discount codes",
description:
  "Retrieve a paginated list of discount codes in a program or filtered by partner, discount, or code.",
requestParams: {
  query: getDiscountCodesQuerySchema,
},
responses: {
  "200": {
    description: "The list of discount codes.",
    content: {
      "application/json": {
        schema: z.array(DiscountCodeSchema),
      },
    },
  },
  ...openApiErrorResponses,
},
tags: ["Discount Codes"],
…
summary: "Retrieve a partner's links.",
description: "Retrieve a partner's links by their partner ID or tenant ID.",
requestParams: {
  query: retrievePartnerLinksSchema,
},
responses: {
  "200": {
    description: "The retrieved partner links.",
    content: {
      "application/json": {
        schema: z.array(ProgramPartnerLinkSchema),
      },
    },
  },
  ...openApiErrorResponses,
},
tags: ["Partners"],
```
Sources: [apps/web/lib/openapi/customers/get-customers.ts:25-42](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/customers/get-customers.ts#L25-L42), [apps/web/lib/openapi/customers/get-customer.ts:11-29](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/customers/get-customer.ts#L11-L29), [apps/web/lib/openapi/discount-codes/list-discount-codes.ts:12-29](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/discount-codes/list-discount-codes.ts#L12-L29), [apps/web/lib/openapi/partners/retrieve-partner-links.ts:10-26](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/partners/retrieve-partner-links.ts#L10-L26)

### Partner Program Telemetry and Analytics

Partner program telemetry encompasses partner link retrieval, analytics, and program applications. The `retrievePartnerLinks` operation queries partner links by partner ID or tenant ID using `retrievePartnerLinksSchema` and returns a list of `ProgramPartnerLinkSchema` items.

```typescript
export const retrievePartnerLinks: ZodOpenApiOperationObject = {
  operationId: "retrievePartnerLinks",
  "x-speakeasy-name-override": "retrieveLinks",
  summary: "Retrieve a partner's links.",
  description: "Retrieve a partner's links by their partner ID or tenant ID.",
  requestParams: {
    query: retrievePartnerLinksSchema,
  },
  responses: {
    "200": {
      description: "The retrieved partner links.",
      content: {
        "application/json": {
          schema: z.array(ProgramPartnerLinkSchema),
        },
      },
    },
    ...openApiErrorResponses,
  },
  tags: ["Partners"],
  security: [{ token: [] }],
};
```
Sources: [apps/web/lib/openapi/partners/retrieve-partner-links.ts:7-28](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/partners/retrieve-partner-links.ts#L7-L28)

Analytics for partners within a program are retrieved via `retrievePartnerAnalytics`, which validates query parameters against `partnerAnalyticsQuerySchema`. The response schema varies via a Zod union supporting count metadata, timeseries arrays, and top-links metrics based on the `groupBy` query parameter.

```typescript
export const retrievePartnerAnalytics: ZodOpenApiOperationObject = {
  operationId: "retrievePartnerAnalytics",
  "x-speakeasy-name-override": "analytics",
  summary: "Retrieve analytics for a partner",
  description:
    "Retrieve analytics for a partner within a program. The response type vary based on the `groupBy` query parameter.",
  requestParams: {
    query: partnerAnalyticsQuerySchema,
  },
  responses: {
    "200": {
      description: "Partner analytics data",
      content: {
        "application/json": {
          schema: z.union([
            partnerAnalyticsResponseSchema.count.meta({
              id: "PartnerAnalyticsCount",
            }),
            z.array(
              partnerAnalyticsResponseSchema.timeseries.meta({
                id: "PartnerAnalyticsTimeseries",
              }),
            ),
            z.array(
              partnerAnalyticsResponseSchema.top_links.meta({
                id: "PartnerAnalyticsTopLinks",
              }),
            ),
          ]),
        },
      },
    },
    ...openApiErrorResponses,
  },
  tags: ["Partners"],
  security: [{ token: [] }],
};
```
Sources: [apps/web/lib/openapi/partners/retrieve-analytics.ts:9-45](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/partners/retrieve-analytics.ts#L9-L45)

Program applications route handles listing applications alongside approval and rejection endpoints under `/program-applications`, `/program-applications/approve`, and `/program-applications/reject`.

```typescript
export const programApplicationsPaths: ZodOpenApiPathsObject = {
  "/program-applications": {
    get: listProgramApplications,
  },
  "/program-applications/approve": {
    post: approveProgramApplication,
  },
  "/program-applications/reject": {
    post: rejectProgramApplication,
  },
};
```
Sources: [apps/web/lib/openapi/program-applications/index.ts:6-16](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/lib/openapi/program-applications/index.ts#L6-L16)

> [!WARNING]
> Partner program summary route handlers are protected with `withPartnerProfile` middleware to ensure authenticated partner context is injected before marketplace telemetry fetchers execute.
> Sources: [apps/web/app/(ee)/api/network/programs/summary/route.ts:1-10](https://github.com/blade47/dub/blob/7c93d97abf0ecd37fb669c6a68a1f7ef9109daea/apps/web/app/(ee)/api/network/programs/summary/route.ts#L1-L10)

## Related

- [OAuth Apps](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/integrations-subsystem/oauth-apps)
- [Link Dispatch](https://www.doc0.dev/docs/934e554a-e6a1-476f-bb2f-23e62d86c3fd/technical/link-attribution/link-dispatch)


## Sitemap

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