# 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.io`
- `cardshare.app`
- `vcards.link`
### Step 1.2: Register the Domain
**Recommended Registrars** (cheapest, no upselling):
| Registrar | .com Price | .io Price | .app Price |
|-----------|------------|-----------|------------|
| [Cloudflare Registrar](https://www.cloudflare.com/products/registrar/) | ~$10/yr | ~$35/yr | ~$15/yr |
| [Porkbun](https://porkbun.com) | ~$10/yr | ~$33/yr | ~$15/yr |
| [Namecheap](https://namecheap.com) | ~$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:
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com)
2. Click "Add a Site"
3. Enter your domain
4. Select the **Free** plan
5. Cloudflare will give you 2 nameservers (e.g., `ada.ns.cloudflare.com`)
6. Go to your registrar and update nameservers to Cloudflare's
7. 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
1. Go to [CloudKit Console](https://icloud.developer.apple.com/dashboard)
2. Sign in with your Apple Developer account
3. Select your container: `iCloud.com.mbrucedogs.BusinessCard`
### Step 2.2: Create the Record Type
1. Click **Schema** in the sidebar
2. Click **Record Types**
3. Click **+** to create new record type
4. Name: `SharedCard`
5. Add these fields:
| Field Name | Type |
|------------|------|
| `vCardData` | String |
| `expiresAt` | Date/Time |
| `createdAt` | Date/Time |
6. Click **Save**
### Step 2.3: Set Security Roles (IMPORTANT)
1. Click **Security Roles** under your record type
2. For the **Public** database:
- **Authenticated Users**: Read, Write (for uploading from main app)
- **World**: Read (for App Clip to fetch without sign-in)
3. Click **Save**
### Step 2.4: Deploy to Production
1. Click **Deploy Schema to Production...**
2. Confirm the deployment
---
## Phase 3: Set Up Cloudflare Worker (~30 minutes)
### Step 3.1: Create the Worker
1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com)
2. Click **Workers & Pages** in the sidebar
3. Click **Create Application**
4. Click **Create Worker**
5. Name it: `businesscard-share` (or similar)
6. Click **Deploy** (creates with placeholder code)
### Step 3.2: Configure Worker Code
1. Click **Edit Code** (or "Quick Edit")
2. Replace all code with:
```javascript
// 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 = `
Opening BusinessCard...
Opening BusinessCard
If the app doesn't open automatically, make sure you have iOS 14 or later.
`;
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 = `
Card Not Found
😕
Card Not Found
This business card may have expired or been removed. Shared cards are available for 7 days.
`;
return new Response(html, {
status: 404,
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
function handleError() {
const html = `
Error
⚠️
Something Went Wrong
We couldn't load this business card. Please try again or ask the sender for a new link.
`;
return new Response(html, {
status: 500,
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
// ============================================================================
// LANDING PAGE
// ============================================================================
function handleLandingPage() {
const html = `
BusinessCard
📇 BusinessCard
Share your digital business card with anyone, even if they don't have the app.
`;
return new Response(html, {
headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
```
3. Click **Save and Deploy**
### Step 3.3: Update Configuration Values
In the worker code, update these values at the top:
```javascript
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
1. In the Worker settings, click **Triggers** tab
2. Click **Add Custom Domain**
3. Enter your domain (e.g., `cards.yourdomain.com` or just `yourdomain.com`)
4. 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:
```json
{"appclips":{"apps":["6R7KLBPBLZ.com.mbrucedogs.BusinessCard.Clip"]}}
```
---
## Phase 4: Configure App Store Connect (~20 minutes)
### Step 4.1: Register App Clip Bundle ID
1. Go to [Apple Developer Portal](https://developer.apple.com/account)
2. Click **Certificates, Identifiers & Profiles**
3. Click **Identifiers** → **+**
4. Select **App IDs** → **App Clip**
5. Enter:
- Description: `BusinessCard App Clip`
- Bundle ID: `com.mbrucedogs.BusinessCard.Clip`
6. Enable capabilities:
- **Associated Domains** ✅
- **iCloud** (CloudKit) ✅
7. Click **Continue** → **Register**
### Step 4.2: Create App Store Connect Record
1. Go to [App Store Connect](https://appstoreconnect.apple.com)
2. Select your BusinessCard app
3. Under **App Clip** section (or create if first time)
4. Configure the App Clip Experience:
- **Title**: BusinessCard
- **Subtitle**: View & save contact
- **Action**: Open
### Step 4.3: Add Invocation URLs
1. In App Clip settings, click **Advanced App Clip Experiences**
2. Add your URL pattern:
- URL: `https://yourdomain.com/appclip`
- Action: Open
---
## 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
1. Select **BusinessCard** scheme (main app - it includes the App Clip)
2. Product → Archive
3. Distribute to App Store Connect
4. Upload to TestFlight
### Step 5.3: Test with TestFlight
1. Install the app via TestFlight on a real device
2. Open the app → Share tab
3. Generate an App Clip link for a card
4. Scan the QR code with another iOS device's camera
5. 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
1. **Check AASA file**: Visit `https://yourdomain.com/.well-known/apple-app-site-association`
2. **Validate with Apple**: Use [AASA Validator](https://branch.io/resources/aasa-validator/)
3. **Check entitlements**: Ensure `appclips:yourdomain.com` is in both app and clip entitlements
4. **Wait for cache**: Apple caches AASA files; may take up to 24 hours
### CloudKit Fetch Fails
1. **Check environment**: Using `development` vs `production`?
2. **Check permissions**: Public database needs "World: Read" permission
3. **Check record exists**: Verify in CloudKit Console
### Android vCard Download Fails
1. **Check Cloudflare Worker logs**: Workers → your-worker → Logs
2. **Test CloudKit endpoint**: Try the fetch manually in worker
3. **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*