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:
- Manual email workflows: Fulfilling orders by hand, copying and pasting URLs into emails.
- PDF wrappers: Uploading a PDF that contains nothing but a hyperlink, which customers download, open, and click through. Terrible UX.
- Separate apps: Installing a dedicated link-selling app (like LinkIT at $14.99–$29/month) on top of their existing digital downloads app.
- Premium tier lock-in: Upgrading to the highest tier of a competing app just to unlock link delivery.
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:
- The links appear on the download page as styled buttons with an external link icon
- The links are included in the delivery email alongside file download buttons
- The links appear in the customer account extension (order status page)
- 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:
- Title (required): Display name shown to customers
- URL (required): Must be HTTPS. Validated at input, at save, and again at redirect time
- Button Label (optional): Custom text for the button customers see. Falls back to the title if not set
- Description (optional): Internal notes for the merchant
- Category (optional): Organize links using the same category system as files
Access Controls
Each link has three independent access control mechanisms:
- Visit Limit (
maxVisits): Maximum number of times the link can be accessed per purchase. Set to 1 for one-time-access links, or any integer for limited access. Leave empty for unlimited. - Duration Expiry (
expiresAfterHours): Link expires N hours after the purchase date. Useful for time-limited webinar access or trial periods. - Calendar Date Expiry (
expiresOn): Link expires on a specific date regardless of purchase date. Useful for cohort-based courses or seasonal content.
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)} ↗
</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:
- Authenticate via Shopify app proxy
- Validate required parameters (download key, link ID)
- Parse link ID with anti-enumeration protection
- Load download record with customer and product mapping data
- Validate the download is not expired
- Check
Purchase.accessEnabled(fraud prevention gate) - Validate link belongs to the purchased product
- Check link-specific expiry (duration-based and calendar-date)
- Re-validate the URL is HTTPS
- 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:
- Different data shape: Links have
url,maxVisits,expiresAfterHours,expiresOn. Files havesize,mimeType,storageKey,cdnUrl. Almost no overlap. - Different access patterns: Files need storage operations (upload, download, CDN signing). Links need visit tracking and redirect validation.
- Clean separation of concerns:
link-function.server.tshandles link CRUD,link-access.server.tshandles visit tracking. No changes tofile-function.server.tsrequired. - ZIP generation: Link-only products automatically skip ZIP generation. No conditional logic needed in the ZIP pipeline.
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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
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:
- Serializable isolation means PostgreSQL will abort one of two concurrent transactions that would violate consistency. If
maxVisitsis 1, exactly one visit will be recorded even under concurrent access. - Retry with exponential backoff (100ms, 200ms, 400ms) handles serialization conflicts gracefully.
- Expiry checks happen before the transaction to avoid acquiring locks unnecessarily for already-expired links.
- Visit count scoped to
(downloadId, linkId)means limits are per-purchase, not global. Different customers each get their own visit allocation.
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:
- Online Courses: Sell enrollment links to Teachable, Thinkific, Kajabi, or Udemy courses. Set a visit limit of 1 for one-time enrollment.
- Community Access: Sell invite links to private Discord servers, Facebook Groups, or Slack workspaces. Use duration-based expiry for time-limited trials.
- Cloud Storage: Sell access to shared Google Drive folders, Dropbox links, or OneDrive directories containing resources and templates.
- Webinars and Events: Sell access to Zoom meetings or event registration links. Set a calendar date expiry to disable access after the event.
- Notion Templates: Sell Notion template duplication links. Customers click once to duplicate the template into their own workspace.
- Membership Sites: Sell registration links to membership portals or Patreon pages.
- Software Portals: Sell access to software download portals, license activation pages, or SaaS onboarding flows.
- Hybrid Products: Combine files and links in a single product. Sell an e-book PDF alongside a private community invite link. The download page renders both seamlessly.
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:
- Link analytics dashboard: Detailed visit analytics per link, including geographic distribution and time-of-day patterns
- Conditional access: Require email verification before granting link access
- Webhook notifications: Fire webhooks when links are accessed for integration with external systems
- Link health monitoring: Periodic checks that external URLs are still valid and accessible
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