19 KiB
App Clip Deployment Guide
This guide walks you through setting up the domain, Cloudflare Worker, and App Store Connect configuration needed for the BusinessCard App Clip to work.
Total Cost: ~$10-15/year (domain only - everything else is free) Time: ~1-2 hours
Overview
User scans QR code
↓
https://yourdomain.com/card/abc123
↓
Cloudflare Worker receives request
↓
┌───────────────────────────────────────┐
│ Serves AASA file to Apple │
│ (verifies App Clip ownership) │
└───────────────────────────────────────┘
↓
┌───────────┴───────────┐
↓ ↓
iOS Android/Browser
↓ ↓
App Clip launches Worker fetches vCard
(native experience) from CloudKit, returns
.vcf file download
Phase 1: Register a Domain (~10 minutes)
Step 1.1: Choose a Domain Name
Pick something short and memorable. Examples:
cards.yourlastname.com(if you own a personal domain)bizcards.iocardshare.appvcards.link
Step 1.2: Register the Domain
Recommended Registrars (cheapest, no upselling):
| Registrar | .com Price | .io Price | .app Price |
|---|---|---|---|
| Cloudflare Registrar | ~$10/yr | ~$35/yr | ~$15/yr |
| Porkbun | ~$10/yr | ~$33/yr | ~$15/yr |
| Namecheap | ~$11/yr | ~$35/yr | ~$16/yr |
Recommendation: Register directly with Cloudflare - simplest setup since you'll use Cloudflare Workers anyway.
Step 1.3: If Not Using Cloudflare Registrar
If you registered elsewhere, add the domain to Cloudflare:
- Go to Cloudflare Dashboard
- Click "Add a Site"
- Enter your domain
- Select the Free plan
- Cloudflare will give you 2 nameservers (e.g.,
ada.ns.cloudflare.com) - Go to your registrar and update nameservers to Cloudflare's
- Wait for DNS propagation (5 min - 24 hours, usually fast)
Phase 2: Create CloudKit Public Database Schema (~15 minutes)
The App Clip needs to read shared cards from CloudKit's public database.
Step 2.1: Open CloudKit Console
- Go to CloudKit Console
- Sign in with your Apple Developer account
- Select your container:
iCloud.com.mbrucedogs.BusinessCard
Step 2.2: Create the Record Type
- Click Schema in the sidebar
- Click Record Types
- Click + to create new record type
- Name:
SharedCard - Add these fields:
| Field Name | Type |
|---|---|
vCardData |
String |
expiresAt |
Date/Time |
createdAt |
Date/Time |
- Click Save
Step 2.3: Set Security Roles (IMPORTANT)
-
Click Security Roles under your record type
-
For the Public database:
- Authenticated Users: Read, Write (for uploading from main app)
- World: Read (for App Clip to fetch without sign-in)
-
Click Save
Step 2.4: Deploy to Production
- Click Deploy Schema to Production...
- Confirm the deployment
Phase 3: Set Up Cloudflare Worker (~30 minutes)
Step 3.1: Create the Worker
- Go to Cloudflare Dashboard
- Click Workers & Pages in the sidebar
- Click Create Application
- Click Create Worker
- Name it:
businesscard-share(or similar) - Click Deploy (creates with placeholder code)
Step 3.2: Configure Worker Code
- Click Edit Code (or "Quick Edit")
- Replace all code with:
// BusinessCard App Clip & vCard Worker
// Handles iOS App Clip verification and Android vCard downloads
// ============================================================================
// CONFIGURATION - Update these values
// ============================================================================
const CONFIG = {
// Your Apple Developer Team ID (find in Apple Developer portal)
teamId: '6R7KLBPBLZ',
// Your App Clip bundle identifier
appClipBundleId: 'com.mbrucedogs.BusinessCard.Clip',
// Your CloudKit container identifier
cloudKitContainer: 'iCloud.com.mbrucedogs.BusinessCard',
// CloudKit environment ('development' or 'production')
cloudKitEnvironment: 'production',
};
// ============================================================================
// MAIN HANDLER
// ============================================================================
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// Handle AASA file for Apple App Clip verification
if (url.pathname === '/.well-known/apple-app-site-association') {
return handleAASA();
}
// Handle card requests (both App Clip and vCard download)
if (url.pathname === '/appclip' || url.pathname.startsWith('/card/')) {
return handleCardRequest(request, url);
}
// Handle root - show a simple landing page
if (url.pathname === '/') {
return handleLandingPage();
}
return new Response('Not Found', { status: 404 });
}
};
// ============================================================================
// AASA HANDLER - Apple App Clip Verification
// ============================================================================
function handleAASA() {
const aasa = {
appclips: {
apps: [`${CONFIG.teamId}.${CONFIG.appClipBundleId}`]
}
};
return new Response(JSON.stringify(aasa), {
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=86400', // Cache for 24 hours
}
});
}
// ============================================================================
// CARD REQUEST HANDLER
// ============================================================================
async function handleCardRequest(request, url) {
// Extract record ID from URL
// Supports: /appclip?id=xxx or /card/xxx
let recordId = url.searchParams.get('id');
if (!recordId && url.pathname.startsWith('/card/')) {
recordId = url.pathname.split('/')[2];
}
if (!recordId) {
return new Response('Missing card ID', { status: 400 });
}
// Check if this is an iOS device (App Clip will handle it natively)
const userAgent = request.headers.get('User-Agent') || '';
const isIOS = /iPhone|iPad|iPod/.test(userAgent);
// For iOS, we could return a page that triggers App Clip,
// but iOS typically intercepts the URL before reaching here.
// This fallback is mainly for Android/desktop browsers.
if (isIOS) {
// Return a simple page that might help if App Clip doesn't auto-launch
return handleIOSFallback(recordId);
}
// For non-iOS devices, fetch the vCard from CloudKit and serve it
return handleVCardDownload(recordId);
}
// ============================================================================
// iOS FALLBACK - In case App Clip doesn't auto-launch
// ============================================================================
function handleIOSFallback(recordId) {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="apple-itunes-app" content="app-clip-bundle-id=${CONFIG.appClipBundleId}">
<title>Opening BusinessCard...</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: white;
text-align: center;
padding: 20px;
box-sizing: border-box;
}
.container { max-width: 400px; }
h1 { font-size: 24px; margin-bottom: 16px; }
p { font-size: 16px; opacity: 0.8; line-height: 1.5; }
.loader {
width: 40px;
height: 40px;
border: 3px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 24px auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="container">
<div class="loader"></div>
<h1>Opening BusinessCard</h1>
<p>If the app doesn't open automatically, make sure you have iOS 14 or later.</p>
</div>
</body>
</html>`;
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
// ============================================================================
// VCARD DOWNLOAD - For Android/Desktop browsers
// ============================================================================
async function handleVCardDownload(recordId) {
try {
// Fetch the record from CloudKit public database
const vCardData = await fetchFromCloudKit(recordId);
if (!vCardData) {
return handleNotFound();
}
// Extract name from vCard for filename
const nameMatch = vCardData.match(/FN:(.+)/);
const name = nameMatch ? nameMatch[1].trim().replace(/[^a-zA-Z0-9]/g, '_') : 'contact';
// Return as downloadable vCard file
return new Response(vCardData, {
headers: {
'Content-Type': 'text/vcard; charset=utf-8',
'Content-Disposition': `attachment; filename="${name}.vcf"`,
'Cache-Control': 'private, max-age=300', // Cache for 5 minutes
}
});
} catch (error) {
console.error('Error fetching vCard:', error);
return handleError();
}
}
// ============================================================================
// CLOUDKIT FETCH
// ============================================================================
async function fetchFromCloudKit(recordName) {
// CloudKit Web Services public database query
// Note: For public database reads, no authentication is needed
const ckURL = `https://api.apple-cloudkit.com/database/1/${CONFIG.cloudKitContainer}/${CONFIG.cloudKitEnvironment}/public/records/lookup`;
const response = await fetch(ckURL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
records: [{ recordName }]
})
});
if (!response.ok) {
console.error('CloudKit error:', await response.text());
return null;
}
const data = await response.json();
if (!data.records || data.records.length === 0) {
return null;
}
const record = data.records[0];
// Check if expired
if (record.fields?.expiresAt?.value) {
const expiresAt = new Date(record.fields.expiresAt.value);
if (expiresAt < new Date()) {
return null; // Expired
}
}
return record.fields?.vCardData?.value || null;
}
// ============================================================================
// ERROR PAGES
// ============================================================================
function handleNotFound() {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Card Not Found</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: white;
text-align: center;
padding: 20px;
box-sizing: border-box;
}
.container { max-width: 400px; }
h1 { font-size: 48px; margin-bottom: 8px; }
h2 { font-size: 24px; margin-bottom: 16px; font-weight: 500; }
p { font-size: 16px; opacity: 0.8; line-height: 1.5; }
</style>
</head>
<body>
<div class="container">
<h1>😕</h1>
<h2>Card Not Found</h2>
<p>This business card may have expired or been removed. Shared cards are available for 7 days.</p>
</div>
</body>
</html>`;
return new Response(html, {
status: 404,
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
function handleError() {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Error</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: white;
text-align: center;
padding: 20px;
box-sizing: border-box;
}
.container { max-width: 400px; }
h1 { font-size: 48px; margin-bottom: 8px; }
h2 { font-size: 24px; margin-bottom: 16px; font-weight: 500; }
p { font-size: 16px; opacity: 0.8; line-height: 1.5; }
</style>
</head>
<body>
<div class="container">
<h1>⚠️</h1>
<h2>Something Went Wrong</h2>
<p>We couldn't load this business card. Please try again or ask the sender for a new link.</p>
</div>
</body>
</html>`;
return new Response(html, {
status: 500,
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
// ============================================================================
// LANDING PAGE
// ============================================================================
function handleLandingPage() {
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BusinessCard</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: white;
text-align: center;
padding: 20px;
box-sizing: border-box;
}
.container { max-width: 400px; }
h1 { font-size: 32px; margin-bottom: 16px; }
p { font-size: 18px; opacity: 0.8; line-height: 1.5; }
</style>
</head>
<body>
<div class="container">
<h1>📇 BusinessCard</h1>
<p>Share your digital business card with anyone, even if they don't have the app.</p>
</div>
</body>
</html>`;
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
- Click Save and Deploy
Step 3.3: Update Configuration Values
In the worker code, update these values at the top:
const CONFIG = {
teamId: '6R7KLBPBLZ', // Your Team ID from Apple Developer Portal
appClipBundleId: 'com.mbrucedogs.BusinessCard.Clip', // From Base.xcconfig
cloudKitContainer: 'iCloud.com.mbrucedogs.BusinessCard', // From Base.xcconfig
cloudKitEnvironment: 'production', // or 'development' for testing
};
Step 3.4: Add Custom Domain to Worker
- In the Worker settings, click Triggers tab
- Click Add Custom Domain
- Enter your domain (e.g.,
cards.yourdomain.comor justyourdomain.com) - Cloudflare will automatically configure DNS
Step 3.5: Verify AASA File
Open in browser: https://yourdomain.com/.well-known/apple-app-site-association
You should see:
{"appclips":{"apps":["6R7KLBPBLZ.com.mbrucedogs.BusinessCard.Clip"]}}
Phase 4: Configure App Store Connect (~20 minutes)
Step 4.1: Register App Clip Bundle ID
- Go to Apple Developer Portal
- Click Certificates, Identifiers & Profiles
- Click Identifiers → +
- Select App IDs → App Clip
- Enter:
- Description:
BusinessCard App Clip - Bundle ID:
com.mbrucedogs.BusinessCard.Clip
- Description:
- Enable capabilities:
- Associated Domains ✅
- iCloud (CloudKit) ✅
- Click Continue → Register
Step 4.2: Create App Store Connect Record
- Go to App Store Connect
- Select your BusinessCard app
- Under App Clip section (or create if first time)
- Configure the App Clip Experience:
- Title: BusinessCard
- Subtitle: View & save contact
- Action: Open
Step 4.3: Add Invocation URLs
- In App Clip settings, click Advanced App Clip Experiences
- Add your URL pattern:
- URL:
https://yourdomain.com/appclip - Action: Open
- URL:
Phase 5: Update Xcode Project (~10 minutes)
Step 5.1: Update Base.xcconfig
Open BusinessCard/Configuration/Base.xcconfig and update:
APPCLIP_DOMAIN = yourdomain.com
Replace yourdomain.com with your actual domain.
Step 5.2: Build and Upload to TestFlight
- Select BusinessCard scheme (main app - it includes the App Clip)
- Product → Archive
- Distribute to App Store Connect
- Upload to TestFlight
Step 5.3: Test with TestFlight
- Install the app via TestFlight on a real device
- Open the app → Share tab
- Generate an App Clip link for a card
- Scan the QR code with another iOS device's camera
- App Clip should launch and show the card preview
Phase 6: Testing Checklist
Local Testing (Simulator)
- Main app builds successfully
- App Clip target builds successfully
- Can generate App Clip URL in Share view
- CloudKit record is created
TestFlight Testing
- App + App Clip installed via TestFlight
- QR code scans and App Clip launches
- Card preview shows correctly with photo
- "Save to Contacts" works
- Android device downloads .vcf file from same URL
Production Readiness
- AASA file accessible at domain
- CloudKit production schema deployed
- App Clip Experience configured in App Store Connect
Troubleshooting
App Clip Doesn't Launch When Scanning QR
- Check AASA file: Visit
https://yourdomain.com/.well-known/apple-app-site-association - Validate with Apple: Use AASA Validator
- Check entitlements: Ensure
appclips:yourdomain.comis in both app and clip entitlements - Wait for cache: Apple caches AASA files; may take up to 24 hours
CloudKit Fetch Fails
- Check environment: Using
developmentvsproduction? - Check permissions: Public database needs "World: Read" permission
- Check record exists: Verify in CloudKit Console
Android vCard Download Fails
- Check Cloudflare Worker logs: Workers → your-worker → Logs
- Test CloudKit endpoint: Try the fetch manually in worker
- Check CORS: Shouldn't be needed for downloads but verify
Summary
| Step | What | Time |
|---|---|---|
| 1 | Register domain | 10 min |
| 2 | CloudKit schema | 15 min |
| 3 | Cloudflare Worker | 30 min |
| 4 | App Store Connect | 20 min |
| 5 | Xcode + TestFlight | 10 min |
| 6 | Testing | 15 min |
| Total | ~2 hours |
Ongoing Cost: ~$10-15/year (domain only)
Last updated: January 10, 2026