Getting Started
Core Architecture
Link Engine
Analytics & Attribution
Partners & Affiliates
Third-Party Integrations
Identity & Security
Automation & Messaging
Developer Tools
The following files were used as context for generating this wiki page:
Dub provides a comprehensive public REST API built on the OpenAPI 3.0 specification, enabling developers to programmatically manage short links, domain names, analytics, and affiliate partner networks at scale. The API specification is dynamically generated using zod-openapi to unify robust runtime request validation with machine-readable contract documentation. Edge middleware handles request normalization, routing, and CORS policies across all public endpoints, while bearer token authentication secures workspace-level operations.
Sources: apps/web/lib/openapi/index.ts:1-27, apps/web/lib/openapi/links/get-links.ts:6-46, apps/web/lib/middleware/api.ts:1-16
Dub generates its unified OpenAPI 3.0 document programmatically using the zod-openapi library, consolidating schema definitions, security schemes, error response components, and path routes into a single export. The generation process starts by passing a configuration object to createDocument, defining metadata such as the API title (Dub API), version (0.0.1), contact details (support@dub.co), AGPL-3.0 licensing information, and production servers (https://api.dub.co).
Sources: apps/web/lib/openapi/index.ts:1-48
The document aggregates path definitions from domain-specific modules, spreading them into the root paths object. These include operations for links, analytics, events, tags, folders, domains, tracking, customers, partners, program applications, discount codes, commissions, payouts, embed tokens, QR codes, and bounties. Reusable schemas are registered under components.schemas, referencing validation definitions such as LinkSchema, LinkTagSchema, FolderSchema, DomainSchema, DiscountCodeSchema, webhookEventSchema, and LinkErrorSchema.
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",
},
],
paths: {
...linksPaths,
...analyticsPath,
...eventsPath,
...tagsPaths,
...foldersPaths,
...domainsPaths,
...trackPaths,
...customersPaths,
...partnersPaths,
...programApplicationsPaths,
...discountCodesPaths,
...commissionsPaths,
...payoutsPaths,
...embedTokensPaths,
...qrCodePaths,
...bountiesPaths,
},
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:26-89
The generated OpenAPI specification is exposed to clients via a Next.js App Router API route handler located at apps/web/app/api/route.ts. The route handler exports a static configuration flag (force-static) and a GET function that serializes the document object using NextResponse.json. To optimize performance and reduce regeneration overhead, caching headers are explicitly attached to the response, setting Vercel-CDN-Cache-Control and Cache-Control to s-maxage=31536000 and public, max-age=31536000 respectively, caching the schema indefinitely until the next deployment.
import { document } from "@/lib/openapi";
import { NextResponse } from "next/server";
export const dynamic = "force-static";
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:1-15
The links path definitions module aggregates individual OpenAPI operation objects for link management into a unified ZodOpenApiPathsObject structure. This collection covers endpoints for creating, listing, updating, deleting, counting, retrieving metadata, and performing bulk or upsert operations on workspace links. Each path mapping binds HTTP methods to specific operation objects configured with query schemas, response codes, tags, and security requirements.
The linksPaths export maps route patterns to their respective HTTP method operations, defining endpoints under /links, /links/count, /links/info, /links/{linkId}, /links/bulk, and /links/upsert.
Individual operation objects configure request validation using Zod query schemas and declare response schemas alongside standard error responses and bearer token security.
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: [] }],
};Metadata retrieval and counting operations utilize specialized query schemas and response types:
getLinkInfo: Mapped to get at /links/info, using getLinkInfoQuerySchema and returning a single LinkSchema object under a 200 status response. Speakeasy SDK generation is customized via "x-speakeasy-name-override": "get".getLinksCount: Mapped to get at /links/count, using getLinksCountQuerySchema and returning a JSON number schema with a descriptive metadata annotation, overridden via "x-speakeasy-name-override": "count".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: [] }],
};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: [] }],
};Server-side REST route handlers for link management, metatags inspection, and iframe validation execute business logic, enforce rate limits and workspace permissions, and return typed responses. These handlers utilize Next.js App Router route conventions, Vercel edge runtimes, and upstream validation utilities.
Sources: apps/web/app/api/links/route.ts:1-114, apps/web/app/api/links/metatags/route.ts:1-52, apps/web/app/api/links/iframeable/route.ts:1-27
The links route module provides GET and POST handlers wrapped with workspace authentication and permission checks.
For retrieval (GET), getLinksQuerySchemaExtended parses search parameters, and validateLinksQueryFilters resolves folder IDs. Workspace limits dictate sorting and search behavior: if workspace.totalLinks exceeds SORTABLE_LINKS_LIMIT (10), sorting falls back to "createdAt"; if it exceeds MEGA_WORKSPACE_LINKS_LIMIT (100,000), search mode switches from "fuzzy" to "exact".
export const GET = withWorkspace(
async ({ headers, searchParams, workspace, session }) => {
const filters = getLinksQuerySchemaExtended.parse(searchParams);
const { folderIds } = await validateLinksQueryFilters({
...filters,
workspace,
sessionUserId: session.user.id,
});
const response = await getLinksForWorkspace({
...filters,
workspaceId: workspace.id,
folderIds,
sortBy:
workspace.totalLinks > SORTABLE_LINKS_LIMIT
? "createdAt"
: filters.sortBy,
searchMode:
workspace.totalLinks > MEGA_WORKSPACE_LINKS_LIMIT ? "exact" : "fuzzy",
});
return NextResponse.json(response, {
headers,
});
},
{
requiredPermissions: ["links.read"],
},
);For creation (POST), usage limits are checked via throwIfLinksUsageExceeded(workspace), and request bodies are parsed and validated using createLinkBodySchemaAsync. If the request is unauthenticated, an IP-based rate limit is asserted using RATELIMIT_POLICIES.anonymousLinkCreate. Links are processed via processLink, wrapped in a DubApiError if validation fails, and committed via createLink. Successful creations trigger an asynchronous background webhook via waitUntil and sendWorkspaceWebhook.
export const POST = withWorkspace(
async ({ req, headers, session, workspace }) => {
if (workspace) {
throwIfLinksUsageExceeded(workspace);
}
const body = await createLinkBodySchemaAsync.parseAsync(
await parseRequestBody(req),
);
if (!session) {
const ip = req.headers.get("x-forwarded-for") || LOCALHOST_IP;
await assertRateLimit({
policy: RATELIMIT_POLICIES.anonymousLinkCreate,
identifier: ip,
});
}
const { link, error, code } = await processLink({
payload: body,
workspace,
...(session && { userId: session.user.id }),
});
if (error != null) {
throw new DubApiError({
code: code as ErrorCodes,
message: error,
});
}
try {
const response = await createLink(link);
if (response.projectId && response.userId) {
waitUntil(
sendWorkspaceWebhook({
trigger: "link.created",
workspace,
data: linkEventSchema.parse(response),
}),
);
}
return NextResponse.json(response, {
headers,
});
} catch (error) {
throw new DubApiError({
code: "unprocessable_entity",
message: error.message,
});
}
},
{
requiredPermissions: ["links.write"],
},
);Warning
Unauthenticated link creation requests rely strictly on the client IP address (x-forwarded-for or LOCALHOST_IP) for anonymous rate limiting enforcement under RATELIMIT_POLICIES.anonymousLinkCreate.
Both the metatags and iframeable inspection endpoints execute on the Vercel edge runtime (export const runtime = "edge").
The metatags endpoint validates request origins ending with .dub.co to assign CORS headers (Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers), validates the target URL parameter against getUrlQuerySchema, enforces IP rate limits using ratelimitOrThrow(req, "metatags"), and fetches metadata via getMetaTags(url). Responses include public caching directives (Cache-Control: public, max-age=300, Vercel-CDN-Cache-Control: s-maxage=3600, stale-while-revalidate=86400).
export async function GET(req: NextRequest) {
try {
const origin = req.headers.get("origin");
const corsHeaders = {
"Access-Control-Allow-Methods": "GET",
"Access-Control-Allow-Headers": "Content-Type",
};
if (origin && origin.endsWith(".dub.co")) {
corsHeaders["Access-Control-Allow-Origin"] = origin;
}
const { url } = getUrlQuerySchema.parse({
url: req.nextUrl.searchParams.get("url"),
});
await ratelimitOrThrow(req, "metatags");
const metatags = await getMetaTags(url);
return NextResponse.json(
{
...metatags,
poweredBy: "Dub - The Modern Link Attribution Platform",
},
{
headers: {
...corsHeaders,
"Cache-Control": "public, max-age=300",
"Vercel-CDN-Cache-Control":
"s-maxage=3600, stale-while-revalidate=86400",
},
},
);
} catch (error) {
return handleAndReturnErrorResponse(error);
}
}The iframeable endpoint parses both URL and domain query parameters using combined Zod schemas (getUrlQuerySchema.and(getDomainQuerySchema)), enforces rate limits via ratelimitOrThrow(req, "iframeable"), and evaluates embedding permissions using isIframeable({ url, requestDomain: domain }).
export async function GET(req: NextRequest) {
try {
const { url, domain } = getUrlQuerySchema
.and(getDomainQuerySchema)
.parse(getSearchParams(req.url));
await ratelimitOrThrow(req, "iframeable");
const iframeable = await isIframeable({ url, requestDomain: domain });
return NextResponse.json({ iframeable });
} catch (error) {
return handleAndReturnErrorResponse(error);
}
}Note
Edge-runtime route handlers such as metatags and iframe validation route unhandled errors directly to handleAndReturnErrorResponse(error) to format standardized JSON error payloads.
Sources: apps/web/app/api/links/metatags/route.ts:48-50, apps/web/app/api/links/iframeable/route.ts:23-25
Dub exposes modular OpenAPI path definitions and tracking endpoints to query analytics and record conversion events, leads, and deep link opens. The analytics query path definition (/analytics) maps to the retrieveAnalytics operation, supporting parameterized queries across aggregate counts, timeseries, and various dimension breakdowns. Concurrently, the tracking namespace defines POST endpoints under /track/lead, /track/sale, and /track/open to record user interactions across mobile and web platforms.
The retrieveAnalytics OpenAPI operation defines a successful 200 response whose content schema is a Zod union covering fifteen distinct analytical data structures. These structures capture count aggregations, time-series metrics, geographical distributions, technical client metadata, and referrer information.
Note
The retrieveAnalytics operation enforces token-based security via security: [{ token: [] }] and overrides its Speakeasy SDK method name to retrieve using the x-speakeasy-name-override attribute.
The /api/track/open endpoint handles POST requests to track when a user opens an application via a Dub-powered deep link on iOS or Android. The operation is defined in OpenAPI via the trackOpen specification object, configured with x-speakeasy-ignore: true and validating request payloads against trackOpenRequestSchema.
The execution follows a strict sequence:
trackOpenRequestSchema.parse() validates the incoming JSON body containing deepLink and dubDomain.ipAddress(req) or LOCALHOST_IP is resolved alongside getIdentityHash(req).deepLink is omitted, the handler falls back to probabilistic IP-based tracking by scanning Upstash Redis for keys matching deepLinkClickCache:${ip}:${dubDomain}:*.deepLink is provided, the handler extracts the domain and key, then concurrently queries global Redis caches for existing click IDs and link metadata via Promise.all.getLinkViaEdge({ domain, key }), formats it with formatRedisLink(), and asynchronously caches it using waitUntil(linkCache.set(...)).nanoid(16) is assigned and recordClick() is invoked with trigger: "deeplink".captureRequestLog() wrapped in waitUntil().Warning
If a request omits both deepLink and dubDomain, trackOpenRequestSchema invokes .superRefine() and throws a validation error requiring at least one parameter for deferred deep linking.
The Dub REST API exposes domain management endpoints under /domains, supporting domain registration, custom domain creation, verification checks, and path configurations. These paths are declared modularly within domainsPaths, grouping the endpoints for listing, creating, patching, deleting, registering, and checking status.
When a client creates a custom domain via a POST /api/domains request, the route handler executes a structured validation, provisioning, and persistence lifecycle.
The call-chain execution proceeds through these steps:
parseRequestBody(req) reads the raw HTTP request body, which is then validated and parsed asynchronously via createDomainBodySchemaExtended.parseAsync(body).logo, expiredUrl, notFoundUrl, assetLinks, appleAppSiteAssociation, deepviewData) are present, throwing a DubApiError with code forbidden if violated.parseDomainJsonConfig().validateDomain(slug). If an error code is returned, it throws a DubApiError.process.env.VERCEL === "1", the domain is provisioned on Vercel infrastructure by calling addDomainToVercel(slug). If Vercel returns an error other than domain_already_in_use, a 422 response is returned.createId({ prefix: "dom_" }), and if a custom logo is provided, it is uploaded to storage via storage.upload().prisma.$transaction), subdomain constraints for .dub.link slugs and workspace domain limits are verified against workspace.domainsLimit before creating the final database record.Caution
Free-tier workspaces attempting to configure custom QR code logos, default expiration URLs, not found URLs, Asset Links, Apple App Site Association, or Deep View data will immediately receive a forbidden API error from the domain creation handler.
The Dub REST API provides modular OpenAPI path definitions and operations for managing partners, program bounties, customers, embed tokens, and QR code generation. These path definitions aggregate separate operation schemas into cohesive OpenAPI path routers using zod-openapi.
The partner program subsystem exposes routes for partner registration, link management, status actions, analytics, and bounty submissions. The analytics endpoint (GET /partners/analytics) supports polymorphic response types evaluated through partnerAnalyticsQuerySchema, returning either count records, timeseries arrays, or top links.
Sources: apps/web/lib/openapi/partners/index.ts:11-32, apps/web/lib/openapi/partners/retrieve-analytics.ts:9-45, apps/web/lib/openapi/bounties/list-bounty-submissions.ts:9-37
Sources: apps/web/lib/openapi/partners/index.ts:11-32, apps/web/lib/openapi/partners/retrieve-analytics.ts:9-11
Note
Bounty submission IDs on Dub are prefixed with bnty_ and can be retrieved using the listBountySubmissions operation (GET /bounties/{bountyId}/submissions) with the token security scheme.
Customer management, referral embed tokens, and QR code generation paths map cleanly to dedicated router structures. The QR code endpoint (GET /qr) returns a raw PNG image response content type (image/png) validated via getQRCodeQuerySchema.
Sources: apps/web/lib/openapi/customers/index.ts:7-16, apps/web/lib/openapi/embed-tokens/index.ts:4-8, apps/web/lib/openapi/qr/index.ts:7-33
Sources: apps/web/lib/openapi/customers/index.ts:8-15, apps/web/lib/openapi/embed-tokens/index.ts:5-7, apps/web/lib/openapi/qr/index.ts:12-23
The Edge middleware architecture coordinates request dispatching, hostname recognition, and normalization across all public and application routes. Running on the Node.js runtime (nodejs), the entry point middleware(req: NextRequest, ev: NextFetchEvent) executes incoming requests by first invoking parse(req) to extract components such as domain, path, key, and fullKey. Axiom logging integrates directly into the request flow via logger.info(...transformMiddlewareRequest(req)) followed by asynchronous flushing through ev.waitUntil(logger.flush()).
Sources: apps/web/middleware.ts:1-40
Incoming requests are filtered through a central matcher config that intercepts all paths except API routes, Next.js internal paths (_next/), third-party proxy paths (_proxy/), and metadata files like favicon.ico, sitemap.xml, robots.txt, and manifest.webmanifest.
Sources: apps/web/middleware.ts:20-32
The dispatch flow executes in a deterministic conditional sequence:
parse(req) extracts request identifiers →isAppHostname(domain) routes to AppMiddleware(req) →API_HOSTNAMES.has(domain) routes to ApiMiddleware(req) →path.startsWith("/stats/") rewrites stats pages →path.startsWith("/.well-known/") rewrites supported files →domain === "dub.sh" && DEFAULT_REDIRECTS[key] redirects shortlinks →ADMIN_HOSTNAMES.has(domain) dispatches to AdminMiddleware(req) →PARTNERS_HOSTNAMES.has(domain) dispatches to PartnersMiddleware(req) →isValidUrl(fullKey) triggers CreateLinkMiddleware(req) →LinkMiddleware(req, ev).Sources: apps/web/middleware.ts:34-89
The ApiMiddleware(req) handler normalizes requests destined for API hostnames by parsing fullPath and evaluating specialized routing rules before forwarding requests to underlying Next.js API routes.
Sources: apps/web/lib/middleware/api.ts:1-16
import { NextRequest, NextResponse } from "next/server";
import { parse } from "./utils/parse";
export function ApiMiddleware(req: NextRequest) {
const { fullPath } = parse(req);
// redirect to dub.co for /metatags
if (fullPath.startsWith("/metatags")) {
return NextResponse.redirect("https://dub.co", {
status: 301,
});
}
// Note: we don't have to account for paths starting with `/api`
// since they're automatically excluded via our middleware matcher
return NextResponse.rewrite(new URL(`/api${fullPath}`, req.url));
}Sources: apps/web/lib/middleware/api.ts:1-16
Warning
Paths starting with /api do not need explicit accounting inside ApiMiddleware because the root middleware matcher configuration explicitly excludes /api/ routes from interception.