Link Selling: Sell Access to Any URL as a Digital Product

Sell courses, communities, Google Drive folders, Notion templates, and any HTTPS URL through your Shopify store with secure access controls and visit tracking.

Published on March 2, 2026 by Alva Digital Downloads Team

Digital products are not just files anymore. Merchants today sell access to online courses on Teachable, invite-only Discord communities, private Google Drive folders, Notion workspace templates, Zoom webinar recordings, and membership portals. Until now, if you wanted to sell URL-based access alongside traditional file downloads on Shopify, you needed either a premium-tier app or a completely separate tool.

Today we are shipping Link Selling in Alva Digital Downloads — a first-class way to sell access to any external HTTPS URL as a digital product. This post covers what it is, how we built it, the security model, and why it matters competitively.

The Problem

A growing number of Shopify merchants sell "digital products" that are not files at all. A fitness instructor sells access to a private Facebook Group. A designer sells a Notion template by sharing a workspace link. A SaaS founder sells early access to a beta portal. An educator sells a Teachable course enrollment link.

These merchants have been forced into awkward workarounds:

All of these approaches are fragile, expensive, or both. They fragment the merchant's workflow across multiple tools and create a worse experience for customers.

What Link Selling Is

Link Selling lets merchants attach external URLs to Shopify products alongside (or instead of) traditional file downloads. When a customer purchases a product with links attached:

  1. The links appear on the download page as styled buttons with an external link icon
  2. The links are included in the delivery email alongside file download buttons
  3. The links appear in the customer account extension (order status page)
  4. Clicking a link routes through a secure redirect endpoint that validates access, checks visit limits, records the visit, and then redirects to the external URL

A single product can now have files, packs (bundles), and links attached to it simultaneously. Or it can be link-only — no files required at all.

Available on All Plans

Link selling is included on every Alva Digital Downloads plan, including Free. No premium tier required. No per-transaction fees.

Competitive Context

This feature was previously gated behind premium tiers or separate apps in the Shopify ecosystem:

App Link Selling Price
Sky Pilot Highest tier only $49+/month
LinkIT Dedicated link-selling app $14.99–$29/month
Digital Downloads (Shopify) Not available Free
SendOwl Not available natively $9–$39/month
Alva Digital Downloads Available on all plans Free – $38.99/month

Merchants who previously needed to stack a second app on top of their digital downloads solution can now consolidate into a single tool.

Feature Overview

Links Management

A new "Links" section appears in the admin navigation. The links index page shows all links with title, truncated URL, visit count, product attachment count, and category assignment. Bulk selection and delete operations are supported.

Link Create/Edit

The link editor provides:

Access Controls

Each link has three independent access control mechanisms:

These can be combined. A link can have both a visit limit of 3 and a duration expiry of 72 hours — whichever limit is hit first takes effect.

Product Attachment

Links are attached to products using the same product mapping UI that files and packs use. The product mapping page now shows three sections: Files, Packs, and Links. A product can have any combination of the three.

Customer Experience

Download Page

Links appear on the download page below any file download buttons. They are rendered as outline-style buttons (transparent background, colored border) with an external link SVG icon — visually distinct from the solid-fill file download buttons. This makes it immediately clear which items are downloadable files and which are external links.

If a product has both files and links, a "Your Links" heading separates the two sections. If a product is link-only, the links appear directly without a separator.

Delivery Email

Links are included in the download email using the same outline-button styling. The email template generates email-safe HTML with escaped URLs, custom button colors, and a north-east arrow character (↗) as a universal "external link" indicator that works even in email clients that strip SVG icons:

// Outline-style buttons for links (distinct from solid-fill file buttons)
const linksHtml = links.map((link) => `
  <div style="text-align: center; margin-bottom: 8px;">
    <a href="${escapeHtml(link.redirectUrl)}"
       target="_blank" rel="noopener noreferrer"
       style="display: inline-block; padding: 10px 24px;
              background-color: transparent;
              color: ${buttonColor};
              border: 2px solid ${buttonColor};
              border-radius: ${buttonRounding}px;
              text-decoration: none;
              font-weight: 600; font-size: 14px;">
      ${escapeHtml(link.title)} &#8599;
    </a>
  </div>`).join("");

Customer Account Extension

Links also appear in the Shopify customer account page (order status) through the existing account extension. Customers can re-access their purchased links at any time without searching for the original email.

Secure Redirect Flow

When a customer clicks a link, they do not go directly to the external URL. Instead, the click routes through a secure redirect endpoint. This endpoint performs ten validation steps before issuing a redirect:

  1. Authenticate via Shopify app proxy
  2. Validate required parameters (download key, link ID)
  3. Parse link ID with anti-enumeration protection
  4. Load download record with customer and product mapping data
  5. Validate the download is not expired
  6. Check Purchase.accessEnabled (fraud prevention gate)
  7. Validate link belongs to the purchased product
  8. Check link-specific expiry (duration-based and calendar-date)
  9. Re-validate the URL is HTTPS
  10. Atomically check visit limits and record the visit (Serializable isolation)

The redirect uses meta http-equiv="refresh" plus JavaScript window.location.href rather than an HTTP 302, because Shopify app proxy routes wrap responses in the store's theme layout and would intercept a raw redirect.

Technical Implementation

Architecture: Separate Model, Not Extending File

We deliberately created a new Link model rather than adding a type field to the existing File model. The reasons:

Following Existing Patterns

The implementation intentionally mirrors the existing file infrastructure:

File Infrastructure Link Infrastructure
file-function.server.ts link-function.server.ts
FileListAndSelectorCard.tsx LinkSelectorCard.tsx
ProductFileMap model ProductLinkMap model
Download model (IP tracking) LinkVisit model (IP tracking)
api.increment-download.ts (Serializable) link-access.server.ts (Serializable)

This means the link selling code is immediately familiar to anyone who has worked on the file delivery code.

Database Design

The migration introduces three new tables with carefully designed indexes:

-- The link definition
CREATE TABLE "Link" (
    "id" SERIAL NOT NULL,
    "shopId" INTEGER NOT NULL,
    "title" TEXT NOT NULL,
    "url" VARCHAR(2048) NOT NULL,
    "buttonLabel" VARCHAR(255),
    "description" TEXT,
    "maxVisits" INTEGER,
    "expiresAfterHours" INTEGER,
    "expiresOn" TIMESTAMP(3),
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" TIMESTAMP(3) NOT NULL,
    "categoryId" INTEGER,
    CONSTRAINT "Link_pkey" PRIMARY KEY ("id")
);

-- Join table: products <-> links (many-to-many via ProductMapping)
CREATE TABLE "ProductLinkMap" (
    "id" SERIAL NOT NULL,
    "productMappingId" TEXT NOT NULL,
    "linkId" INTEGER NOT NULL,
    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT "ProductLinkMap_pkey" PRIMARY KEY ("id")
);

-- Audit trail for every link access
CREATE TABLE "LinkVisit" (
    "id" SERIAL NOT NULL,
    "downloadId" INTEGER NOT NULL,
    "linkId" INTEGER NOT NULL,
    "ipAddress" TEXT NOT NULL,
    "visitedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
    CONSTRAINT "LinkVisit_pkey" PRIMARY KEY ("id")
);

The LinkVisit_downloadId_linkId_idx composite index is critical for performance. The Serializable transaction counts visits by (downloadId, linkId) on every link click. Without this index, the count query would scan the entire LinkVisit table.

All foreign keys use ON DELETE CASCADE, so merchants can safely delete links, products, or even uninstall the app without leaving orphaned records.

Full i18n Support

All user-facing strings — admin UI, download page, email content, error messages — are fully translated across all 35+ supported locales. A new links translation namespace was added to every locale directory. The download page translations were extended with link-specific keys (linkNotFound, linkExpired, linkVisitLimitReached, redirectingToLink, etc.) so that customers see error messages in their own language.

Security Deep Dive

Security was the primary design concern for link selling. Unlike file downloads where we control the content and delivery via signed CDN URLs, link selling redirects customers to external URLs that we do not control. This introduces new attack surfaces that required careful mitigation.

HTTPS-Only URL Enforcement

URLs are validated at three independent layers:

1. Input validation (html-utils.ts):

export function validateLinkUrl(url: string):
  { valid: boolean; error?: string } {
  if (!url || url.trim().length === 0) {
    return { valid: false, error: "URL is required" };
  }
  if (url.length > 2048) {
    return { valid: false,
      error: "URL must be 2048 characters or less" };
  }
  try {
    const parsed = new URL(url);
    if (parsed.protocol !== "https:") {
      return { valid: false,
        error: "Only HTTPS URLs are allowed" };
    }
    return { valid: true };
  } catch {
    return { valid: false, error: "Invalid URL format" };
  }
}

2. Database layer: The url column is VARCHAR(2048), and the createLink/updateLink functions call validateLinkUrl before any write.

3. Redirect-time re-validation: The redirect endpoint parses the URL again and rejects non-HTTPS protocols before redirecting. This protects against a scenario where a URL was somehow stored before validation was added, or if the database were modified directly.

XSS Prevention

Merchant-controlled strings (link titles, button labels, URLs) are rendered in HTML contexts on the download page and in emails. All of these are escaped using a dedicated escapeHtml function:

export function escapeHtml(str: string): string {
  return str
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#039;");
}

On the redirect page, the URL is placed in a data-url attribute (escaped) and read via dataset.url in JavaScript, avoiding direct interpolation into a script context:

<div id="redirect-target" data-url="${safeUrl}">
  <script>
    (function() {
      var url = document.getElementById(
        'redirect-target').dataset.url;
      window.location.href = url;
    })();
  </script>
</div>

Race-Condition-Safe Visit Tracking

Visit limits must be enforced atomically. If a customer opens two tabs simultaneously, both tabs should not be able to consume the same "slot." We use PostgreSQL Serializable isolation — the same pattern we use for file download count tracking:

export async function checkAndRecordLinkVisit(
  downloadId: number,
  linkId: number,
  link: LinkForAccessCheck,
  purchaseDate: Date,
  ipAddress: string
): Promise<{ allowed: boolean; reason?: string }> {
  // Check expiry before entering the transaction
  const expiryCheck = checkLinkExpiry(link, purchaseDate);
  if (expiryCheck.expired) {
    return { allowed: false, reason: expiryCheck.reason };
  }

  const MAX_RETRIES = 3;
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
    try {
      const result = await prisma.$transaction(
        async (tx) => {
          const visitCount = await tx.linkVisit.count({
            where: { downloadId, linkId },
          });

          if (link.maxVisits !== null
              && visitCount >= link.maxVisits) {
            return {
              allowed: false as const,
              reason: "visitLimitReached"
            };
          }

          const visit = await tx.linkVisit.create({
            data: { downloadId, linkId, ipAddress },
          });

          return { allowed: true as const, visit };
        },
        {
          isolationLevel: "Serializable",
          maxWait: 10000,
          timeout: 10000,
        }
      );
      return result;
    } catch (error: unknown) {
      // Retry on serialization conflicts (P2034/40001)
      const isSerializationError = error
        && typeof error === "object"
        && "code" in error
        && ((error as any).code === "P2034"
          || (error as any).code === "40001");

      if (isSerializationError
          && attempt < MAX_RETRIES - 1) {
        // Exponential backoff: 100ms, 200ms, 400ms
        await new Promise((r) =>
          setTimeout(r, Math.pow(2, attempt) * 100)
        );
        continue;
      }
      throw error;
    }
  }
  throw new Error(
    "Failed to process link visit after retries"
  );
}

Key points about this approach:

Anti-Enumeration

All error responses for link validation failures return identical HTML structures with generic error messages. Whether the link ID is invalid, the link does not belong to the product, or the link has been deleted, the customer sees the same "Link not found" page. This prevents attackers from probing for valid link IDs.

Referrer Suppression

The redirect page includes <meta name="referrer" content="no-referrer"> to prevent the external URL from seeing the download key in the referrer header. Without this, the destination site's access logs would contain the full redirect URL including the customer's download key, which could be used to access other downloads.

Fraud Prevention Integration

Link access respects the existing fraud prevention system. The redirect endpoint checks Purchase.accessEnabled before granting access. If an order has been flagged by the fraud detection system and access has been disabled, the customer cannot access any links from that purchase until the merchant manually approves the order.

Use Cases

Link selling opens up a wide range of product types that merchants can now sell through their Shopify store:

Getting Started with Link Selling

Link selling is available now on all Alva Digital Downloads plans. Navigate to the "Links" section in your admin, create a link with a title and HTTPS URL, configure access controls, and attach it to any product. Your customers will see the link on their download page, in their email, and in their customer account — all automatically.

What's Next

Link selling ships today and is available on all plans. We are planning several enhancements:

If you are a Shopify merchant selling digital products — whether they are files, links, or both — install Alva Digital Downloads and try link selling today.

Ready to Sell Links as Digital Products on Shopify?

Join thousands of merchants using Alva Digital Downloads to sell files, links, and hybrid products with secure delivery and fraud prevention.

Start Free 14-Day Trial