Invitation Link (Mobile)

This guide demonstrates how to implement a robust, stateless invitation system for mobile applications using DyLy deep links. Users can invite others to join groups, teams, or access specific features within your app, with a seamless experience whether the app is already installed or not.

Benefits

Seamless Experience: App opens directly to the relevant content (group, feature, etc.)
Works Without Installation: Gracefully handles app-not-installed scenario
Stateless Architecture: No backend state required for invitation tracking
Secure Data Transfer: JWT-signed tokens ensure data integrity
Attribution Tracking: Track which invitations led to app installs
Cross-Platform: Works on both iOS and Android with standard protocols

Common Use Cases

  • Social Features: Friend referrals, group invitations, team memberships
  • Collaboration: Shared document access, project invitations
  • Gaming: Friend challenges, guild invitations, matchmaking
  • E-Commerce: Referral programs, shared carts, gift sharing
  • Content Sharing: Shared photos, videos, articles within the app

Architecture Overview

The invitation flow uses Universal Links (iOS) and App Links (Android) to seamlessly transition users from web to app.

Components

  1. Inviter's App: Where the invitation is initiated
  2. DyLy Service: Generates deep links with signed tokens
  3. Operating System: Routes deep links to the app (iOS/Android)
  4. Invitee's Device: Receives and processes the invitation
  5. Your Backend (optional): Validates tokens and processes invitations

Key Concepts

  • Universal Links (iOS): Web links that open the app instead of Safari
  • App Links (Android): Android's equivalent to Universal Links
  • Deep Links: URLs that navigate to specific app screens
  • Deferred Deep Links: Preserve invitation context through app installation
  • JWT Tokens: Signed data included in links for security and context

Prerequisites

Before implementing invitation links, you must configure your domains for Universal Links and App Links.

Why This Matters

For deep links to work, the operating system needs to know which apps should handle which domains. This is done through well-known endpoint files that associate your domain with your app.

Required Endpoints

You must create two JSON links for mobile deep linking:

  1. .well-known/apple-app-site-association - For iOS Universal Links
  2. .well-known/assetlinks.json - For Android App Links

Without these, deep links will open in the browser instead of your app.

Step-by-Step Implementation

Step 1: Create Well-Known Endpoints

These JSON links tell iOS and Android that your domain should open your app.

Create a JSON link at .well-known/apple-app-site-association:

POST /url/api/v1/links
{
  "type": "json",
  "projectId": "your-project-id",
  "path": ".well-known/apple-app-site-association",
  "jsonContent": {
    "applinks": {
      "apps": [],
      "details": [
        {
          "appIDs": ["TEAM123.com.example.app"],
          "paths": [
            "NOT /_/*",
            "NOT /deferred-params",
            "/*"
          ]
        }
      ]
    }
  }
}

Key Parameters:

  • appIDs: Your app's Team ID + Bundle ID (find in Apple Developer Portal)
  • paths: URL patterns that should open the app
    • "NOT /_/*": Excludes internal API paths
    • "NOT /deferred-params": Critical - prevents app launch when fetching deferred parameters
    • "/*": All other paths open the app

Where to Find Your Team ID:

  1. Go to Apple Developer Portal
  2. Navigate to Membership
  3. Copy your Team ID (10-character string like TEAM123456)

Finding Your Bundle ID:

  • In Xcode: Target → General → Bundle Identifier
  • Or in your Info.plist file

Create a JSON link at .well-known/assetlinks.json:

POST /url/api/v1/links
{
  "type": "json",
  "projectId": "your-project-id",
  "path": ".well-known/assetlinks.json",
  "jsonContent": [
    {
      "relation": ["delegate_permission/common.handle_all_urls"],
      "target": {
        "namespace": "android_app",
        "package_name": "com.example.app",
        "sha256_cert_fingerprints": [
          "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:16:A0:83:42:E6:1D:BE:A8:8A:04:96:B2:3F:CF:44:E5"
        ]
      }
    }
  ]
}

Key Parameters:

  • package_name: Your Android app's package name (e.g., com.example.app)
  • sha256_cert_fingerprints: SHA-256 hash of your signing certificate

Getting Your SHA-256 Certificate Fingerprint:

For debug builds:

keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android

For release builds:

keytool -list -v -keystore /path/to/my-release-key.keystore -alias my-alias

Copy the SHA256 value and format with colons (e.g., AA:BB:CC:...).

Verify Your Endpoints

After creating the links, verify they're accessible:

iOS:

curl https://your-domain.dyly.dev/.well-known/apple-app-site-association

Android:

curl https://your-domain.dyly.dev/.well-known/assetlinks.json

Both should return JSON (not HTML or error pages).

Step 2: Configure Your Mobile Apps

iOS App Configuration

1. Add Associated Domains Entitlement (Xcode):

  • Target → Signing & Capabilities → + Capability → Associated Domains
  • Add domain: applinks:your-domain.dyly.dev

2. Handle Universal Links in your app delegate:

// AppDelegate.swift or SceneDelegate.swift
func application(_ application: UIApplication,
                 continue userActivity: NSUserActivity,
                 restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else {
        return false
    }
    
    // Handle deep link
    return handleDeepLink(url: url)
}

func handleDeepLink(url: URL) -> Bool {
    // Extract token from URL query parameter
    guard let token = url.queryParameter("token") else {
        return false
    }
    
    // Verify token signature
    guard let claims = verifyAndDecodeToken(token) else {
        // Invalid token
        return false
    }
    
    // Extract invitation data
    let invitationId = claims["invitationId"] as? String
    let groupId = url.pathComponents.last // e.g., "group-a"
    
    // Navigate to appropriate screen
    navigateToGroup(groupId: groupId, invitationId: invitationId)
    
    return true
}

Android App Configuration

1. Add Intent Filter in AndroidManifest.xml:

<activity android:name=".MainActivity">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        
        <data
            android:scheme="https"
            android:host="your-domain.dyly.dev" />
    </intent-filter>
</activity>

2. Handle Deep Links in your Activity:

// MainActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    
    // Handle incoming deep link
    handleIntent(intent)
}

override fun onNewIntent(intent: Intent?) {
    super.onNewIntent(intent)
    intent?.let { handleIntent(it) }
}

private fun handleIntent(intent: Intent) {
    val action = intent.action
    val data = intent.data
    
    if (action == Intent.ACTION_VIEW && data != null) {
        // Extract token
        val token = data.getQueryParameter("token")
        
        if (token != null) {
            // Verify and decode token
            val claims = verifyAndDecodeToken(token)
            
            // Extract invitation data
            val invitationId = claims.optString("invitationId")
            val groupId = data.lastPathSegment
            
            // Navigate to group screen
            navigateToGroup(groupId, invitationId)
        }
    }
}

When a user initiates an invitation, your app creates a deep link through the DyLy API.

API Request:

POST https://dyly-api.lilacwells.com/url/api/v1/links
Content-Type: application/json
Authorization: Basic YOUR_CREDENTIALS

{
  "type": "deep",
  "projectId": "your-project-id",
  "path": "groups/awesome-group",
  "destinationUrl": "https://apps.apple.com/app/yourapp/id123456",
  "jwtClaims": {
    "invitationId": "inv-789xyz",
    "invitedBy": "user-123",
    "groupId": "awesome-group",
    "groupName": "Awesome Group",
    "permissions": "member",
    "timestamp": 1735894404
  },
  "jwtExpiresIn": 300,
  "expiresIn": 2592000,
  "keyProtected": false,
  "oneTime": false
}

Parameter Explanations:

  • type: Must be "deep" for deep links
  • path: The deep link path (e.g., groups/awesome-group)
    • This becomes the app's route when opened
    • Should match your app's URL routing structure
  • destinationUrl: Fallback URL when app is not installed
    • Usually your app's App Store or Play Store page
    • Or a web landing page explaining the invitation
  • jwtClaims: Custom data to pass to the app
    • invitationId: Unique identifier for this invitation
    • invitedBy: User who sent the invitation
    • groupId: Target group/resource
    • groupName: Human-readable name for display
    • permissions: What permissions the invitee will have
    • Add any other contextual data your app needs
  • jwtExpiresIn: JWT token validity (300s = 5 minutes)
    • Short expiration for security
    • Token is validated when app opens, not when link is clicked
  • expiresIn: Link expiration (2592000s = 30 days)
    • How long the invitation link remains valid
    • Reasonable for typical invitation scenarios
  • keyProtected: Usually false for invitations
    • Set to true if invitations should be unpredictable
    • Trade-off between security and convenience
  • oneTime: Usually false for invitations
    • Set to true if invitation should only work once
    • Consider if re-clicking should be allowed

API Response:

{
  "projectId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "destinationUrl": "https://apps.apple.com/app/yourapp/id123456",
  "path": "groups/awesome-group",
  "shortUrl": "https://myapp.dyly.dev/groups/awesome-group?token=eyJhbGci...",
  "alias": "bXlwcmlqZWN0LmR5bHkuYXBwIy53ZWxsLWtub3duL29wZW5pZC1jb25maWd1cmF0aW9u",
  "expiresIn": 2592000,
  "jwtExpiresIn": 300,
  "createdAt": "2025-07-04T00:26:18.666Z",
  "oneTime": false,
  "type": "deep",
  "keyProtected": false
}

Share the Link: Send shortUrl to the invitee via:

  • In-app sharing (native share sheet)
  • SMS
  • Email
  • Messaging apps (WhatsApp, Telegram, etc.)
  • QR code

When the app is already installed, the experience is seamless.

Flow:

  1. User clicks https://myapp.dyly.dev/groups/awesome-group?token=...
  2. iOS: System recognizes Universal Link, launches app with URL
  3. Android: System recognizes App Link, launches app with Intent
  4. App receives the deep link with token parameter
  5. App extracts and validates the token
  6. App navigates to the group screen with invitation context

Token Validation (your app must implement):

// Example: JavaScript/React Native token validation
async function verifyAndDecodeToken(token) {
  try {
    // Fetch public keys
    const jwks = await fetch('https://myapp.dyly.dev/.well-known/jwks.json');
    const keys = await jwks.json();
    
    // Verify JWT signature
    const decoded = await verifyJWT(token, keys);
    
    // Validate claims
    if (decoded.iss !== 'myapp.dyly.dev') {
      throw new Error('Invalid issuer');
    }
    
    if (decoded.exp < Date.now() / 1000) {
      throw new Error('Token expired');
    }
    
    // Extract path from token and verify it matches the link
    const currentPath = extractPathFromURL(window.location.href);
    if (decoded.path !== currentPath) {
      throw new Error('Path mismatch - possible tampering');
    }
    
    return decoded;
  } catch (error) {
    console.error('Token validation failed:', error);
    return null;
  }
}

Security Checks:

  1. ✅ Verify JWT signature using JWKS
  2. ✅ Check iss (issuer) matches your domain
  3. ✅ Check exp (expiration) hasn't passed
  4. ✅ Verify path in token matches actual link path (anti-tampering)
  5. ✅ Validate custom claims (invitationId, groupId, etc.)

Handle Valid Invitation:

async function handleInvitation(claims) {
  const { invitationId, invitedBy, groupId, groupName } = claims;
  
  // Show invitation UI
  const accepted = await showInvitationDialog({
    message: `Join ${groupName}?`,
    invitedBy: invitedBy
  });
  
  if (accepted) {
    // Process invitation
    await api.acceptInvitation({
      invitationId,
      groupId,
      userId: currentUser.id
    });
    
    // Navigate to group
    navigation.navigate('GroupScreen', { groupId });
  }
}

When the app is not installed, DyLy provides deferred deep linking to preserve the invitation context.

Flow:

  1. User clicks the invitation link
  2. Browser opens (app not installed, so OS doesn't intercept)
  3. Browser requests https://myapp.dyly.dev/groups/awesome-group?token=...
  4. DyLy sets a cookie containing the link information
  5. DyLy redirects to destinationUrl (App Store / Play Store)
  6. User downloads and installs the app
  7. On first launch, app calls the deferred parameters endpoint
  8. DyLy returns the original link token with matching confidence
  9. App processes the invitation as if the link was clicked

Key Innovation: The cookie + deferred parameters mechanism allows invitations to work even when the app isn't installed yet.

Deferred Parameters API Request:

GET https://myapp.dyly.dev/deferred-params?clientId=your-client-id
Cookie: [automatically included by browser]

Important:

  • Call this from the device's browser or webview (where the cookie was set)
  • Include cookies in the request (credentials: 'include' in fetch)
  • Call on first app launch after installation

Deferred Parameters API Response:

{
  "token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "matchingScore": 0.95
}

Matching Score Interpretation:

  • 1.0: Perfect match (cookie found) - safe to use
  • 0.9-0.99: Very strong match (cookie + IP/UA match) - very likely correct
  • 0.7-0.89: Good match (IP + UA match) - probably correct, use with caution
  • 0.6-0.69: Weak match (partial IP/UA match) - low confidence
  • < 0.6: No match - DyLy returns 404

Using Matching Score:

async function getDeferredInvitation() {
  try {
    const response = await fetch(
      'https://myapp.dyly.dev/deferred-params?clientId=your-client-id',
      { credentials: 'include' } // Critical: include cookies
    );
    
    if (!response.ok) {
      // No deferred deep link found (404)
      return null;
    }
    
    const { token, matchingScore } = await response.json();
    
    // Define your confidence threshold
    const MIN_CONFIDENCE = 0.8;
    
    if (matchingScore < MIN_CONFIDENCE) {
      console.warn(`Low matching confidence: ${matchingScore}`);
      // Optionally: show user a confirmation dialog
      const confirmed = await askUser(
        "We found an invitation that might be for you. Accept it?"
      );
      if (!confirmed) return null;
    }
    
    // Verify and decode token (same as step 4A)
    const claims = await verifyAndDecodeToken(token);
    return claims;
    
  } catch (error) {
    console.error('Failed to fetch deferred params:', error);
    return null;
  }
}

// Call on app first launch
async function onFirstLaunch() {
  const invitation = await getDeferredInvitation();
  
  if (invitation) {
    // Process the deferred invitation
    await handleInvitation(invitation);
  } else {
    // Normal first-launch flow
    showWelcomeScreen();
  }
}

Matching Algorithm:

DyLy uses multiple signals to match the deferred request to the original click:

  1. Cookie Match (highest confidence: 1.0)

    • If the session cookie is present and valid
    • Most reliable method
  2. IP Address + User Agent (medium-high confidence: 0.7-0.9)

    • Compares IP address and browser/device user agent
    • Works when cookies are unavailable
    • Less reliable due to shared IPs (WiFi, mobile networks)
  3. No Match (< 0.6)

    • Cannot confidently match the request
    • Returns 404

When Cookie Matching Fails:

Cookies might not work in these scenarios:

  • User installs app on different device than where they clicked
  • User clears browser data before installing app
  • Browser privacy settings block cookies
  • Long delay between click and installation (cookie expired)

For these cases, fallback to IP/UA matching with a confirmation prompt.

Step 5: Validate Token and Process Invitation

Whether from direct deep link or deferred parameters, always validate the token.

Complete Validation Flow:

async function processInvitationToken(token) {
  // 1. Verify JWT signature
  const claims = await verifyAndDecodeToken(token);
  if (!claims) {
    throw new Error('Invalid token signature');
  }
  
  // 2. Check expiration
  if (claims.exp < Date.now() / 1000) {
    throw new Error('Invitation expired');
  }
  
  // 3. Validate required claims
  if (!claims.invitationId || !claims.groupId) {
    throw new Error('Missing required invitation data');
  }
  
  // 4. (Optional) Check invitation with backend
  const invitationValid = await api.validateInvitation({
    invitationId: claims.invitationId,
    invitedBy: claims.invitedBy
  });
  
  if (!invitationValid) {
    throw new Error('Invitation no longer valid');
  }
  
  // 5. Process invitation
  return claims;
}

Backend Validation (optional but recommended):

// Backend API endpoint
app.post('/api/invitations/validate', async (req, res) => {
  const { invitationId, invitedBy } = req.body;
  
  // Check invitation status in database
  const invitation = await db.invitations.findOne({
    id: invitationId,
    invitedBy: invitedBy,
    status: 'pending'
  });
  
  if (!invitation) {
    return res.status(404).json({ valid: false });
  }
  
  // Check if invitation expired
  if (invitation.expiresAt < new Date()) {
    return res.status(410).json({ valid: false, reason: 'expired' });
  }
  
  return res.json({ valid: true, invitation });
});

Complete Flow Diagram

Invitation Deep Link Sequence Diagram

This sequence diagram illustrates the complete invitation flow for both scenarios: when the app is installed and when it's not.

Security Considerations

Token Security

JWT Signing:

  • ✅ All tokens are signed by DyLy using RS256
  • ✅ Always verify signatures before trusting token contents
  • ✅ Use the JWKS endpoint to get public keys

Token Expiration:

  • ✅ Set short jwtExpiresIn (5-10 minutes) for security
  • ✅ Keep link expiresIn longer (days/weeks) for user convenience
  • ✅ Token is only validated when app opens, not when link is clicked

Tampering Protection:

  • ✅ Include path verification in token validation
  • ✅ Check that token's claims match the actual link
  • ✅ Verify issuer matches your domain

Privacy Considerations

Data in JWT:

  • ⚠️ JWT contents are not encrypted, only signed
  • ⚠️ Anyone can decode and read the JWT (but can't modify it)
  • ⚠️ Don't include sensitive data (passwords, payment info, etc.)
  • ✅ Include only necessary invitation context (IDs, permissions)

What's Safe to Include:

  • ✅ User IDs (public identifiers)
  • ✅ Group/resource IDs
  • ✅ Permissions levels
  • ✅ Non-sensitive metadata

What to Avoid:

  • ❌ Passwords or secrets
  • ❌ Personal identifiable information (PII)
  • ❌ Payment information
  • ❌ Private messages or content

Deferred Deep Linking Security

Matching Confidence:

  • Set appropriate threshold (recommend ≥ 0.8)
  • For high-value actions, require 1.0 (cookie match only)
  • Lower threshold may result in misattributed invitations

Attack Scenarios:

Scenario: Shared IP Attack

  • Multiple users on same WiFi click different invitations
  • One user installs app and might get wrong invitation
  • Mitigation: Use high matching threshold, ask for confirmation

Scenario: Delayed Installation

  • User clicks invitation, installs app days later
  • Cookie might be expired or cleared
  • Mitigation: Set reasonable link expiration, provide resend option

Best Practices

Always Validate Tokens: Never trust token contents without signature verification
Set Appropriate Expirations: Balance security (short JWT expiration) with UX (longer link expiration)
Use Confirmation Dialogs: Ask user to confirm invitation acceptance
Implement Backend Validation: Check invitation status server-side before accepting
Monitor for Abuse: Track invitation usage patterns to detect spam or abuse
Rate Limiting: Limit how many invitations one user can send
Audit Logging: Log all invitation creation and acceptance for security auditing

Edge Cases and Troubleshooting

Common Issues

Issue: App doesn't open when clicking link

Possible Causes:

  • Well-known endpoints not configured correctly
  • Associated Domains entitlement missing (iOS)
  • Intent filter missing (Android)
  • Domain not verified
  • Testing on simulator (Universal Links don't work on iOS simulator)

Solutions:

  • ✅ Verify well-known endpoints are accessible via curl
  • ✅ Check Associated Domains in Xcode includes your domain
  • ✅ Verify intent filter has android:autoVerify="true"
  • ✅ Test on real device, not simulator
  • ✅ Check console logs for deep link errors

Issue: Token validation fails

Possible Causes:

  • JWKS URL incorrect
  • Clock skew between systems
  • Token expired
  • Wrong algorithm

Solutions:

  • ✅ Verify JWKS URL: https://your-domain.dyly.dev/.well-known/jwks.json
  • ✅ Synchronize system clocks (use NTP)
  • ✅ Check token exp claim hasn't passed
  • ✅ Support RS256 algorithms

Issue: Deferred parameters return 404

Possible Causes:

  • No link was clicked before app install
  • Link clicked from different device
  • Cookies blocked or cleared
  • Too much time passed (cookie expired)
  • Called from server instead of browser

Solutions:

  • ✅ Ensure request includes cookies (credentials: 'include')
  • ✅ Call from same device/browser that clicked link
  • ✅ Test within a short time window after clicking
  • ✅ Handle 404 gracefully (normal first install without invitation)

Issue: Low matching score

Possible Causes:

  • Shared IP address (office, school WiFi)
  • VPN or proxy usage
  • Browser changed user agent
  • Time gap between click and install

Solutions:

  • ✅ Set appropriate threshold (0.8 recommended)
  • ✅ Show confirmation dialog for low scores
  • ✅ Provide option to manually enter invitation code
  • ✅ Log matching scores for analysis
  • ✅ Prompt users to click the link again

Issue: Invitation processed multiple times

Possible Causes:

  • oneTime not set to true
  • No backend validation
  • User clicking link multiple times
  • App crash after processing

Solutions:

  • ✅ Implement idempotency in invitation processing
  • ✅ Check invitation status in backend
  • ✅ Mark invitation as used after acceptance
  • ✅ Handle gracefully if already accepted

Testing Checklist

Happy Path Testing

  • ✅ App installed: Click invitation → App opens → Correct group shown
  • ✅ App not installed: Click → Install → Launch → Invitation processed
  • ✅ Token validation succeeds
  • ✅ Group/feature is accessible after accepting invitation

Security Testing

  • ✅ Expired token is rejected
  • ✅ Tampered token (modified claims) is rejected
  • ✅ Invalid signature is rejected
  • ✅ Wrong issuer is rejected
  • ✅ Path mismatch is detected

Edge Case Testing

  • ✅ App already installed but opened manually (no invitation)
  • ✅ Multiple invitations clicked before install
  • ✅ Clicking same invitation twice
  • ✅ Expired invitation link
  • ✅ Invitation to non-existent group/resource

Platform Testing

  • ✅ iOS Universal Links work
  • ✅ Android App Links work
  • ✅ Deferred deep linking works on both platforms
  • ✅ Web fallback works (app not installed, link opened in browser)
  • ✅ Cookie-based matching works
  • ✅ IP/UA fallback matching works

UX Testing

  • ✅ Clear invitation dialog/screen
  • ✅ Error messages are user-friendly
  • ✅ Graceful handling of expired invitations
  • ✅ Option to decline invitation
  • ✅ Loading states during verification

Advanced Topics

Invitation Analytics

Track invitation performance:

async function trackInvitation(claims, source) {
  await analytics.track('invitation_received', {
    invitationId: claims.invitationId,
    invitedBy: claims.invitedBy,
    groupId: claims.groupId,
    source: source, // 'deep-link' or 'deferred'
    matchingScore: source === 'deferred' ? claims.matchingScore : 1.0,
    timestamp: new Date().toISOString()
  });
}

Metrics to Track:

  • Invitations sent vs accepted
  • Time to acceptance
  • Install attribution (deferred deep links)
  • Matching score distribution
  • Invitation expiration before use

Multiple Invitation Types

Support different invitation types with the same infrastructure:

{
  "jwtClaims": {
    "invitationType": "group",
    "invitationId": "inv-123",
    "groupId": "awesome-group",
    ...
  }
}
function handleInvitation(claims) {
  switch (claims.invitationType) {
    case 'group':
      return handleGroupInvitation(claims);
    case 'event':
      return handleEventInvitation(claims);
    case 'document':
      return handleDocumentInvitation(claims);
    default:
      throw new Error('Unknown invitation type');
  }
}

Invitation with Prerequisites

Require certain conditions before accepting:

async function validatePrerequisites(claims) {
  // Check if user meets requirements
  if (claims.minimumLevel && currentUser.level < claims.minimumLevel) {
    throw new Error('User level too low');
  }
  
  if (claims.requiresVerification && !currentUser.verified) {
    throw new Error('Email verification required');
  }
  
  // Check capacity
  const group = await api.getGroup(claims.groupId);
  if (group.memberCount >= group.maxMembers) {
    throw new Error('Group is full');
  }
}

Next Steps

Additional Resources