Update, Upsert, and Delete Links

Purpose and Scope

This page explains the mutating side of Dub's Links API: updating existing links, upserting a link by destination URL, and deleting links. In Dub terminology, a link is the short-link resource that connects a short domain and key to a destination URL, analytics, attribution metadata, tags, and program-related context. These operations are the ones to reach for after links already exist and a workspace needs to revise metadata, idempotently create-or-return a link, or remove links from circulation.

The public API documentation defines this family around PATCH /links/bulk, PUT /links/upsert, and DELETE /links/bulk. The supplied repository evidence for this page is centered on custom-domain deletion, which is still important for link deletion semantics because domain deletion is explicitly destructive for associated links. The OpenAPI domain operation describes domain deletion as irreversible and states that it also deletes every link associated with the domain, so consumers should treat domain lifecycle changes as another path that can remove links from a workspace. Sources: apps/web/lib/openapi/domains/delete-domain.ts

Use update when the same metadata change should be applied to a bounded set of known links, such as assigning tags, setting an expiration date, or applying shared UTM parameters. Use upsert when the caller wants idempotent behavior by URL: Dub returns an existing matching link, updates it if supplied properties changed, or creates a new link when no match exists. Use delete when the caller deliberately wants to remove link records. Because deletion is destructive and cannot be undone, production integrations should usually perform a read or count step before issuing destructive calls.

Relevant Source Files

  • apps/web/lib/openapi/domains/delete-domain.ts - Defines the OpenAPI operation for deleting a domain and documents that deleting a domain also deletes all links associated with that domain.
  • apps/web/app/(ee)/api/cron/domains/delete/route.ts - Implements the asynchronous domain-deletion worker that deletes links in batches, clears link cache entries, records deleted links in Tinybird, removes associated R2 images, deletes rows from MySQL through Prisma, and decrements the workspace link count.
  • apps/web/app/(ee)/api/cron/workspaces/delete/delete-workspace-domains.ts - Shows workspace teardown deleting registered domains and workspace domains in batches, then removing domains from Vercel before advancing the workspace deletion workflow.
  • apps/web/lib/api/domains/mark-domain-deleted.ts - Marks a domain as deleted by removing it from Vercel, detaching it from its project, and queueing the cron deletion that removes the domain and links.
  • apps/web/app/(ee)/admin.dub.co/(dashboard)/domains/components/refresh-domain.tsx - Provides an admin dashboard form that posts a domain refresh request and reports success or error through toast notifications.
  • apps/web/app/(ee)/admin.dub.co/(dashboard)/domains/components/register-premium-domain.tsx - Provides an admin dashboard flow for searching premium .link domain availability and registering a premium domain to a workspace.

Public Operation Reference

The bulk update operation is PATCH /links/bulk. It updates up to 100 links in the authenticated workspace with the same data object. The request accepts linkIds and externalIds, both capped at 100 items, with linkIds taking precedence when both are supplied. This endpoint is best suited to uniform edits across a selected set of records. The official API docs call out two constraints that matter for client design: it cannot update the link domain or key, and webhook events are not triggered by this bulk operation.

The upsert operation is PUT /links/upsert. It is URL-centered: if a link with the same destination URL already exists in the authenticated workspace, Dub returns that link and may update it when the submitted properties differ. Otherwise, Dub creates a new link. The request schema includes common link creation fields such as url, domain, key, and keyLength. If no domain is provided, the workspace primary domain is used, or dub.sh is used when the workspace has no domains. If no key is provided, a random slug is generated.

The bulk delete operation is DELETE /links/bulk. It deletes up to 100 links for the authenticated workspace using a required linkIds query parameter encoded as a comma-separated array. Non-existing IDs are ignored, and a successful response returns an object containing deletedCount. This is a destructive endpoint, and the official documentation notes that webhook events are not triggered for bulk deletion. API clients should therefore not depend on webhooks to observe this path and should record their own audit or reconciliation state before calling it.

OperationMethod and pathPrimary inputResultImportant constraint
Bulk update linksPATCH /links/bulklinkIds or externalIds, plus shared dataUpdates selected links with identical dataMaximum 100 links; cannot change domain or key; no webhook events
Upsert a linkPUT /links/upsertDestination url plus optional link fieldsReturns, updates, or creates a linkMatching is by URL in the authenticated workspace
Bulk delete linksDELETE /links/bulkComma-separated linkIds query parameterReturns deletedCountMaximum 100 IDs; destructive; no webhook events

Deletion Semantics and Domain Cascades

Although the requested operation family is about links, Dub's repository evidence shows that link deletion is not only a direct Links API action. The domain delete OpenAPI operation says deleting a domain from a workspace cannot be undone and will delete all links associated with that domain. That matters because the domain portion of a short link is not just display metadata; it is part of the addressability of the link. Removing the domain invalidates the associated short URLs and triggers cleanup work for every link using it. Sources: apps/web/lib/openapi/domains/delete-domain.ts

The domain deletion worker is designed as a batch process rather than a single unbounded transaction. It verifies the QStash signature, parses a JSON body containing domain, loads the domain record, and selects up to 100 links for that domain ordered by newest creation time. The selected links are loaded with tags and program enrollment context, which lets downstream deletion recording preserve the information needed for analytics and attribution cleanup. This batch size mirrors the broader API posture of keeping destructive link operations bounded. Sources: apps/web/app/(ee)/api/cron/domains/delete/route.ts

For each batch, Dub performs several cleanup steps concurrently. It deletes the links from Redis through linkCache.deleteMany, records the links to Tinybird with { deleted: true }, deletes link images from R2 storage when the image URL is under the expected R2_URL prefix, deletes link rows with prisma.link.deleteMany, and decrements the owning project's totalLinks count. The code uses Promise.allSettled, logs rejected promises, and then checks how many links remain for the domain before deciding whether to queue another deletion pass. Sources: apps/web/app/(ee)/api/cron/domains/delete/route.ts

That implementation reveals two practical expectations for API consumers. First, deletion has multiple side effects beyond removing a database row: cache, analytics, file storage, and workspace counters all need to converge. Second, large deletion sets are intentionally drained through repeated work rather than one monolithic operation. If an integration performs bulk link deletion directly, it should assume that downstream analytics or cached state may be updated through separate infrastructure paths, especially when deletion is related to domain or workspace lifecycle operations. Sources: apps/web/app/(ee)/api/cron/domains/delete/route.ts

Workspace and Admin Flows Around Deletion

The markDomainAsDeleted helper shows how Dub starts a domain-driven link cleanup. It removes the domain from Vercel, updates the domain row so projectId becomes null, queues domain deletion, and logs any rejected operation. This is a soft handoff pattern: the user-facing operation detaches the domain and schedules the heavier deletion workflow, while the cron route performs the actual batch deletion of links and final domain removal. Sources: apps/web/lib/api/domains/mark-domain-deleted.ts, apps/web/app/(ee)/api/cron/domains/delete/route.ts

Workspace deletion has a related but broader cleanup path. The workspace domain deletion step loads up to MAX_DOMAINS_PER_BATCH, which is set to 10, deletes registered-domain records for the workspace, deletes domain rows for the current batch, asks Vercel to remove each domain, and then enqueues the next workspace deletion step. This source file does not define the Links API, but it reinforces the same design principle: destructive cleanup is batched, explicit, and part of a multi-step workflow rather than an incidental side effect hidden in a UI component. Sources: apps/web/app/(ee)/api/cron/workspaces/delete/delete-workspace-domains.ts

The admin dashboard domain components show the operational surfaces around this lifecycle. RefreshDomain posts to /api/admin/domains/refresh with a domain value and displays success or error via sonner toasts. RegisterPremiumDomain normalizes input into a .link domain, searches availability through /api/admin/domains/search-availability, validates that the result is both premium and available, confirms the purchase price, and posts to /api/admin/domains/register-premium. These flows are not link mutation endpoints, but they help administrators control the domain inventory that links depend on. Sources: apps/web/app/(ee)/admin.dub.co/(dashboard)/domains/components/refresh-domain.tsx, apps/web/app/(ee)/admin.dub.co/(dashboard)/domains/components/register-premium-domain.tsx

Implementation Signals for Client Developers

When building against the link update and delete API family, keep the resource boundaries clear. Bulk link update changes fields on existing link records but deliberately does not move links to another domain or change their short key. Upsert is intended for idempotent creation around a URL and therefore belongs in provisioning flows where duplicate links would be undesirable. Bulk delete should be reserved for explicit removal workflows with confirmation, audit logging, or prior lookup, because neither the official bulk delete endpoint nor domain deletion semantics describe an undo path.

A safe client flow usually starts by resolving the intended link IDs, showing the user the exact count and selection, and then choosing the narrowest mutation. For uniform metadata edits, send PATCH /links/bulk with no more than 100 identifiers and one shared data object. For URL-based idempotency, send PUT /links/upsert and let Dub decide whether to return, update, or create. For removal, send DELETE /links/bulk?linkIds=... and store the returned deletedCount for reconciliation. If the operation is related to domain removal, expect the domain cleanup worker to delete associated links in batches. Sources: apps/web/lib/openapi/domains/delete-domain.ts, apps/web/app/(ee)/api/cron/domains/delete/route.ts

// Bulk update: apply the same metadata to selected links.
await fetch("https://api.dub.co/links/bulk", {
  method: "PATCH",
  headers: {
    Authorization: `Bearer ${process.env.DUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    linkIds: ["link_1", "link_2"],
    data: {
      expiresAt: "2026-01-01T00:00:00.000Z",
      utmCampaign: "new-year",
    },
  }),
});
 
// Upsert: return, update, or create a link by destination URL.
await fetch("https://api.dub.co/links/upsert", {
  method: "PUT",
  headers: {
    Authorization: `Bearer ${process.env.DUB_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://example.com/pricing",
    domain: "go.example.com",
    key: "pricing",
  }),
});
 
// Bulk delete: remove selected links and reconcile deletedCount.
await fetch("https://api.dub.co/links/bulk?linkIds=link_1,link_2", {
  method: "DELETE",
  headers: {
    Authorization: `Bearer ${process.env.DUB_API_KEY}`,
  },
});

Next Steps

Read the Links API overview before designing a full integration, then pair this page with the create, retrieve, and list reference so your client can identify records before mutating them. If your workflow operates on many records, also review bulk link operations as a separate planning topic. If deletion is connected to a custom-domain change, read the Domains API and custom-domain lifecycle pages, because deleting a domain can remove every associated link and trigger the asynchronous cleanup path described here.