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.
Why Use Deep Links for Invitations?
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
- Inviter's App: Where the invitation is initiated
- DyLy Service: Generates deep links with signed tokens
- Operating System: Routes deep links to the app (iOS/Android)
- Invitee's Device: Receives and processes the invitation
- 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:
.well-known/apple-app-site-association- For iOS Universal Links.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.
For iOS - Universal Links
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:
- Go to Apple Developer Portal
- Navigate to Membership
- Copy your Team ID (10-character string like
TEAM123456)
Finding Your Bundle ID:
- In Xcode: Target → General → Bundle Identifier
- Or in your
Info.plistfile
For Android - App Links
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)
}
}
}
Step 3: Create Deep Link for Invitation
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 linkspath: 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 appinvitationId: Unique identifier for this invitationinvitedBy: User who sent the invitationgroupId: Target group/resourcegroupName: Human-readable name for displaypermissions: 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: Usuallyfalsefor invitations- Set to
trueif invitations should be unpredictable - Trade-off between security and convenience
- Set to
oneTime: Usuallyfalsefor invitations- Set to
trueif invitation should only work once - Consider if re-clicking should be allowed
- Set to
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
- Messaging apps (WhatsApp, Telegram, etc.)
- QR code
Step 4A: User Clicks Link (App Installed)
When the app is already installed, the experience is seamless.
Flow:
- User clicks
https://myapp.dyly.dev/groups/awesome-group?token=... - iOS: System recognizes Universal Link, launches app with URL
- Android: System recognizes App Link, launches app with Intent
- App receives the deep link with token parameter
- App extracts and validates the token
- 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:
- ✅ Verify JWT signature using JWKS
- ✅ Check
iss(issuer) matches your domain - ✅ Check
exp(expiration) hasn't passed - ✅ Verify path in token matches actual link path (anti-tampering)
- ✅ 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 });
}
}
Step 4B: User Clicks Link (App NOT Installed)
When the app is not installed, DyLy provides deferred deep linking to preserve the invitation context.
Flow:
- User clicks the invitation link
- Browser opens (app not installed, so OS doesn't intercept)
- Browser requests
https://myapp.dyly.dev/groups/awesome-group?token=... - DyLy sets a cookie containing the link information
- DyLy redirects to
destinationUrl(App Store / Play Store) - User downloads and installs the app
- On first launch, app calls the deferred parameters endpoint
- DyLy returns the original link token with matching confidence
- 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:
-
Cookie Match (highest confidence: 1.0)
- If the session cookie is present and valid
- Most reliable method
-
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)
-
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
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
expiresInlonger (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
expclaim 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:
oneTimenot 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
- Implement Invitations: Follow this guide to add invitation links to your mobile app
- Review Magic Links: Learn about implementing magic link authentication
- Explore Deep Links: Understand more about deep link types and features
- API Reference: Dive into the complete API documentation