# LinkForty — Complete Documentation > LinkForty is an open-source mobile attribution and deep link management platform. It provides link creation, redirect routing, device fingerprinting, deferred deep linking, install attribution, click analytics, web analytics via a tracking pixel, and in-app event tracking. Available as a managed SaaS (Cloud) at linkforty.com or self-hosted via the `@linkforty/core` npm package. SDKs available for React Native, Expo, iOS (Swift), Android (Kotlin), Flutter, and the web. This file is the full text of https://docs.linkforty.com/, in sidebar order. The index with one line per page is at https://docs.linkforty.com/llms.txt. The product overview for linkforty.com is at https://linkforty.com/llms.txt. # Getting Started ## Why LinkForty? Source: https://docs.linkforty.com/why-linkforty/ LinkForty is an open-source mobile attribution and deeplink management platform designed for teams that need complete control over their data and infrastructure. LinkForty comes in two flavors: - **[LinkForty Core](https://github.com/linkforty/core)** - The open-source deeplink engine you self-host. It handles redirect routing, click tracking, analytics, and link CRUD via a Fastify server backed by PostgreSQL and optional Redis caching. - **[LinkForty Cloud](https://linkforty.com)** - A fully managed SaaS platform built on top of Core. It adds a dashboard UI, team management, custom domains, and more - so you can get started without managing any infrastructure. Both share the same battle-tested attribution engine. Choose the option that fits your team. #### Core Philosophy We built LinkForty on three principles: 1. **Data Sovereignty** - Your attribution data should live in your infrastructure (or in a platform you trust) 2. **Transparency** - Attribution logic should be auditable and understandable - Core is fully open source 3. **Developer Freedom** - You should be able to modify, extend, and integrate your attribution platform however you need --- #### Key Benefits ##### Complete Data Ownership With Core, your attribution data never leaves your infrastructure. With Cloud, your data lives in a dedicated, secure environment managed by us. **Perfect for:** - Privacy-conscious companies - Regulated industries (healthcare, finance, government) - Organizations with data sovereignty requirements - Companies handling sensitive user information - GDPR/CCPA compliance needs **What this means:** - No data sharing with third-party vendors - Full control over data retention policies - Choose your own backup and disaster recovery (Core) or rely on managed backups (Cloud) - Integrate with your existing data warehouse - No concerns about vendor data breaches --- ##### Open Source & Transparent The Core engine is fully open source and available for inspection and modification. **Benefits:** - Audit the complete attribution logic - Understand exactly how attribution works (no black boxes) - Modify algorithms for your specific use case - Contribute improvements back to the community - Fix bugs without waiting for vendor releases **Core Tech Stack:** - **Server**: Node.js + Fastify - **Database**: PostgreSQL - **Cache**: Redis (optional) - **Mobile SDK**: React Native **Cloud adds:** - **Dashboard**: React 18 + TypeScript - **Billing**: Stripe integration - **Custom domains**: Cloudflare-powered - **Team management**: Organizations, roles, and invitations All Core code is available on [GitHub](https://github.com/linkforty/core). --- ##### Self-Hosted or Fully Managed Deploy Core anywhere, or let Cloud handle everything for you. **Self-Hosted (Core):** - AWS, Google Cloud, Azure, DigitalOcean, or your own data center - Choose your instance sizes and regions - Scale horizontally or vertically - Use your existing PostgreSQL cluster - No external rate limits or quotas **Fully Managed (Cloud):** - No infrastructure to manage - Automatic updates and security patches - Built-in monitoring and backups - Dashboard for link management and analytics - Get started in minutes at [linkforty.com](https://linkforty.com) --- ##### Developer-First Design Built by developers, for developers. **API & Integration:** - Simple, well-documented REST API - Webhook support for real-time events - Bulk operations for efficiency - React Native SDK for mobile integration - TypeScript types included **Documentation:** - Comprehensive guides and tutorials - API reference with examples - Migration guides from other platforms **Modern Stack:** - Written in TypeScript for type safety - Fastify for high-performance backend - PostgreSQL for reliability --- ##### No Vendor Lock-In Your data is yours, forever. **Data Portability:** - Standard PostgreSQL database schema - Export data anytime (CSV, JSON, SQL) - No proprietary formats - Full database access - Own your complete attribution history **Migration Freedom:** - Easy to import from other platforms - Easy to export to other solutions - No long-term contracts - No cancellation fees - Take your data anywhere --- #### Feature Overview ##### Core Attribution - **Fingerprint-based attribution** - Match clicks to installs without requiring user IDs - **Deep linking** - Route users to specific in-app content - **Deferred deep linking** - Preserve deep link data through app install - **UTM parameter tracking** - Track campaign sources and performance - **Multi-platform support** - iOS, Android, Web - **Configurable attribution windows** - Customize matching timeframes ##### Analytics & Reporting - **Real-time dashboard** - See attribution data instantly (Cloud) or query via API (Core) - **Campaign analytics** - Measure performance by source, medium, campaign - **Geographic breakdowns** - See where your users come from (via IP geolocation) - **Device analytics** - iOS vs Android performance - **Data export** - Download data in CSV or JSON ##### Link Management - **Link templates** - Reusable configurations for campaigns - **Bulk operations** - Create and manage thousands of links - **QR code generation** - Offline-to-online attribution - **Custom short codes** - Memorable, branded links - **Link expiration** - Time-limited campaigns ##### Developer Tools - **REST API** - Full programmatic access - **Webhooks** - Real-time event notifications - **React Native SDK** - Mobile integration for deferred deep linking and device fingerprinting - **[MCP server](https://docs.linkforty.com/guides/advanced/mcp-server/)** - Connect Claude, Cursor, and Claude Code to your workspace via natural language (open source, free on every plan) - **TypeScript support** - Type-safe development - **Docker support** - Easy deployment --- #### Who Should Use LinkForty? ##### LinkForty Core Is Ideal For **Privacy-First Companies** - Need complete data sovereignty - Subject to strict data regulations (GDPR, CCPA, HIPAA) - Handle sensitive user information - Value transparency in data handling **High-Growth Apps** - Need unlimited attribution without per-install fees - Require scalability without vendor constraints - Process millions of attributions per month - Want control over infrastructure **Developer-Centric Teams** - Comfortable managing infrastructure - Want to customize attribution logic - Need full API access and flexibility - Prefer open-source solutions ##### LinkForty Cloud Is Ideal For **Teams Without DevOps Resources** - Want enterprise features without managing servers - Need a dashboard for non-technical team members - Prefer automatic updates and maintenance **Startups & Scale-ups** - Want to get started quickly without infrastructure setup - Need team collaboration features (organizations, roles, projects) - Want custom domains for branded links - Value predictable, transparent pricing **Anyone Who Wants Both** - Start with Cloud, move to self-hosted later - same underlying engine - Use Cloud for convenience, know you can always take your data and self-host --- #### What LinkForty does LinkForty gives you the core of modern attribution and deep linking: - Fingerprint-based install attribution and deferred deep linking - One link across iOS, Android, and web - Campaign tracking with UTM parameters - Real-time analytics and webhook events ##### What makes it different **Architecture:** - **Open source** core engine — inspect exactly how attribution works, no black box - **Self-hosted or managed** - you choose your deployment model - **You own your data** regardless of which option you pick **Model:** - **No per-install fees** and no artificial attribution limits - **Core is free** - you only pay for your own infrastructure **Philosophy:** - **Privacy-first** by default (no IDFA/GAID, no persistent device IDs) - **Full transparency** in attribution logic - **Complete customization** (Core) ##### Honest trade-offs LinkForty is focused and lightweight, which means: - Fewer pre-built integrations out of the box — wire up the ones you need via the API and webhooks - The open-source Core is community-supported (Cloud plans include direct support) - Self-hosting means running and patching your own infrastructure — or use managed Cloud to skip it entirely --- #### Getting Started Ready to try LinkForty? ##### Option 1: LinkForty Cloud (Fastest) Sign up at [linkforty.com](https://linkforty.com) and start creating links in minutes. No infrastructure to manage. ##### Option 2: Self-Host with Core Run LinkForty locally with Docker: ```bash git clone https://github.com/linkforty/core cd core docker-compose up ``` Visit [http://localhost:3000](http://localhost:3000) to see it in action. **Next Steps:** - [Quick Start Guide](https://docs.linkforty.com/getting-started/quick-start/) - [Self-Hosting Guide](https://docs.linkforty.com/getting-started/self-hosting/) - [Cloud vs Self-Hosted](https://docs.linkforty.com/getting-started/cloud-vs-self-hosted/) ##### Migration Support Moving from self-hosted Core to Cloud? - [Core → Cloud Migration Guide](https://docs.linkforty.com/migrations/core-to-cloud/) --- #### Community & Support ##### Get Help - [Documentation](https://docs.linkforty.com/) ##### Contribute - [Contributing Guide](https://github.com/linkforty/core/blob/main/CONTRIBUTING.md) - [Report Issues](https://github.com/linkforty/core/issues) ##### Stay Updated - [GitHub](https://github.com/linkforty/core) - Star and watch for updates --- #### Frequently Asked Questions ##### Is LinkForty Core really free? Yes. LinkForty Core is open-source software licensed under the AGPL-3.0. You only pay for the infrastructure to host it (AWS, GCP, etc.). ##### Do you offer managed hosting? Yes! [LinkForty Cloud](https://linkforty.com) provides a fully managed solution so you don't have to worry about infrastructure, updates, or maintenance. ##### How accurate is LinkForty's attribution? LinkForty uses fingerprint-based attribution. Accuracy depends on: - Proper SDK integration - Attribution window configuration - User behavior patterns We've seen 85-95% attribution match rates in production deployments. ##### Can I customize the attribution logic? Yes! Since LinkForty Core is open source, you can modify any part of the attribution algorithm. Common customizations: - Custom fingerprinting logic - Industry-specific attribution models - Custom attribution windows per campaign ##### What if I need help? Community support is available via: - [GitHub Discussions](https://github.com/linkforty/core/discussions) - [GitHub Issues](https://github.com/linkforty/core/issues) For a managed experience with built-in support, consider [LinkForty Cloud](https://linkforty.com). ##### Is LinkForty production-ready? Yes. LinkForty is running in production, processing millions of attributions per month. We recommend: - Thorough testing in staging first - Gradual rollout to production - Monitoring setup before full deployment See our [Production Checklist](https://docs.linkforty.com/deployment/production-checklist/). --- *AppsFlyer, Adjust, and Branch are registered trademarks of their respective owners. LinkForty is not affiliated with or endorsed by these companies.* --- ## Introduction to LinkForty Source: https://docs.linkforty.com/getting-started/introduction/ LinkForty is an open-source mobile attribution and deeplink management platform that gives you enterprise features without the enterprise price tag. LinkForty comes in two flavors: - **[LinkForty Core](https://github.com/linkforty/core)** - The open-source deeplink engine you self-host. It provides a REST API for link management, device-specific redirects, click analytics, deferred deep linking, QR codes, and webhooks - all backed by PostgreSQL and optional Redis caching. - **[LinkForty Cloud](https://linkforty.com)** - A fully managed SaaS platform built on top of Core. It adds a dashboard UI, team management, custom domains, billing, and more - so you can get started without managing any infrastructure. Both share the same battle-tested attribution engine. Choose the option that fits your team. --- #### What Does LinkForty Do? At its core, LinkForty creates smart short links that route users to the right destination based on their device. When someone clicks a LinkForty link: 1. **Web users** are redirected to your website or a custom fallback URL 2. **Mobile users with the app installed** are deep linked directly into your app content 3. **Mobile users without the app** are sent to the App Store or Google Play, and the original deep link is preserved for after install (deferred deep linking) Every click is tracked with full analytics - geolocation, device type, platform, UTM parameters, and more. --- #### Feature Overview ##### Core Features (Open Source) These features are available in both Core (self-hosted) and Cloud: **Link Management** - Short links with device-specific routing (iOS, Android, web) - Custom short codes for branded links - Link expiration for time-sensitive campaigns - Targeting rules for device, country, and language filtering - UTM parameter support for campaign tracking - Bulk link operations **Mobile Attribution** - Deferred deep linking - preserve deep link data through app install - Fingerprint-based attribution for matching clicks to installs - Configurable attribution windows (1 hour to 90 days) - Click-to-install tracking **Analytics** - Real-time click tracking - Geographic distribution (country, city) - Device and platform breakdown (iOS, Android, web) - Campaign performance via UTM parameters - Data export (JSON and CSV) **Developer Tools** - REST API with full programmatic access - Webhook notifications for real-time events - QR code generation (PNG and SVG, custom colors and sizes) - Social media previews (Open Graph tags) - Mobile SDKs for deep link handling and device fingerprinting - TypeScript types included --- ##### Cloud-Only Features These features are exclusive to [LinkForty Cloud](https://linkforty.com): **Dashboard** - Visual link management interface - Analytics dashboard with charts and graphs - No API knowledge required - accessible to non-technical team members **Team Collaboration** - Multi-organization support - Role-based access control (Owner, Admin, Member, Viewer) - Team invitations via email - Project-based link organization **Custom Domains** - Use your own domain for branded short links - Cloudflare-powered with automatic SSL - Available on all plans (1 domain on Free, more on paid plans) **Link Templates** - Reusable link configurations for campaigns - Pre-configured platform URLs, attribution windows, and UTM parameters - Create links faster with consistent settings **Managed Infrastructure** - Automatic updates and security patches - Built-in monitoring and backups - Global CDN for fast redirects worldwide - 99.9% uptime SLA on paid plans **Billing & Plans** - Free tier (1,000 links, 5,000 clicks/month) - Pro plan (25,000 links, 100,000 clicks/month) - Business plan (100,000 links, 500,000 clicks/month) - Unlimited plan (unlimited everything) - See the [pricing page](https://linkforty.com/pricing) for current plan pricing --- #### Tech Stack **Core Engine:** - **Server**: Node.js + Fastify - **Database**: PostgreSQL - **Cache**: Redis (optional) - **Language**: TypeScript (strict mode) - **License**: AGPL-3.0 **Cloud Platform (adds):** - **Dashboard**: React 18 + TypeScript - **Billing**: Stripe integration - **Custom Domains**: Cloudflare-powered - **Team Management**: Organizations, roles, and invitations - **Email**: Nodemailer / SendGrid **Mobile SDKs:** - **React Native**: [`@linkforty/mobile-sdk-react-native`](https://docs.linkforty.com/sdks/react-native/) - **Expo**: [`@linkforty/mobile-sdk-expo`](https://docs.linkforty.com/sdks/expo/) - **iOS (Swift)**: [`LinkFortySDK`](https://docs.linkforty.com/sdks/ios/) - **Android (Kotlin)**: [`com.linkforty:sdk`](https://docs.linkforty.com/sdks/android/) - **Flutter**: [`linkforty_flutter`](https://docs.linkforty.com/sdks/flutter/) --- #### Use Cases ##### E-commerce Create product links that route users to App Store or Google Play, then deep link directly to that product after install. Track which ads and campaigns drive purchases. ##### Referral Programs Generate unique referral links per user. Attribute new installs to referrers and award credits automatically via webhooks. ##### Content Apps Share article links on social media with rich Open Graph previews. New users install the app and land on that exact article via deferred deep linking. ##### Marketing Campaigns Track which channels (Facebook, Instagram, TikTok, email) drive installs. Measure true ROI with click-to-install attribution and UTM parameter tracking. --- #### Getting Started Choose your path: - **[Quick Start](https://docs.linkforty.com/getting-started/quick-start/)** - Get started with LinkForty Cloud in 5 minutes (no credit card required) - **[Self-Hosting Guide](https://docs.linkforty.com/getting-started/self-hosting/)** - Deploy LinkForty Core on your own infrastructure - **[Cloud vs Self-Hosted](https://docs.linkforty.com/getting-started/cloud-vs-self-hosted/)** - Compare deployment options to find the right fit #### Next Steps - [Creating Your First Link](https://docs.linkforty.com/guides/links/creating-links/) - Step-by-step link creation guide - [Mobile SDK Integration](https://docs.linkforty.com/guides/attribution/sdk-integration/) - Set up deep linking in your app - [Analytics Dashboard](https://docs.linkforty.com/guides/analytics/dashboard/) - Understand your attribution data - [API Reference](https://docs.linkforty.com/api/authentication/) - Full API documentation --- ## Quick Start Guide - Get Started with LinkForty Cloud in 5 Minutes Source: https://docs.linkforty.com/getting-started/quick-start/ Get started with LinkForty Cloud in under 5 minutes. No credit card required. **Info: Looking to self-host?** This guide covers **LinkForty Cloud** (the managed SaaS platform). If you want to deploy LinkForty Core on your own infrastructure, see the [Self-Hosting Guide](https://docs.linkforty.com/getting-started/self-hosting/). #### 1. Create Your Account Visit [linkforty.com](https://linkforty.com) and sign up for a free account. **Free Tier includes:** - 1,000 links - 5,000 clicks/month - Core features (attribution, QR codes, analytics) - 1 organization, up to 3 team members - 1 custom domain - 7-day analytics retention No credit card required. Upgrade later when you need more capacity. #### 2. Create Your First Organization After signing in, you'll be prompted to create your first organization: 1. Click **"Create Organization"** 2. Enter your organization name (e.g., "Acme Inc") 3. Click **"Create"** Organizations allow you to: - Separate personal and business links - Invite team members with role-based access - Manage multiple projects - Track separate analytics per organization #### 3. Create Your First Template **Info: Important** **Templates must be created before links.** Templates define default settings (like app store URLs, attribution windows, and UTM parameters) that apply to all links created from that template. ##### Step 1: Navigate to Templates 1. Click **"Settings"** in the sidebar 2. Click **"Link Templates"** 3. Click **"Create Template"** button ##### Step 2: Configure Your Template Fill in the template details: **Basic Information:** - **Name**: "Default Template" or "Instagram Campaigns" - **Description**: "Standard template for social media campaigns" **Default Platform URLs:** - **iOS URL**: `https://apps.apple.com/app/your-app/id123456789` - **Android URL**: `https://play.google.com/store/apps/details?id=com.yourapp` - **Web Fallback**: `https://example.com` (optional) **Default Attribution Window:** - Select **168 hours (7 days)** - Industry standard **Default UTM Parameters** (optional): - **Medium**: `social` (all links from this template will have this) Click **"Create Template"** and you're ready to create links. #### 4. Create Your First Link ##### Step 1: Navigate to Links 1. Click **"Links"** in the sidebar 2. Click **"Create Link"** button ##### Step 2: Select Your Template - **Template**: Select "Default Template" (the template you just created) The template will automatically fill in default values for app store URLs and attribution window. ##### Step 3: Configure Your Link Fill in the required fields: **Basic Information:** - **Original URL**: `https://example.com/product/123` (where you want to send users) - **Title**: "Product 123 Launch" (internal reference) - **Description**: "Q1 product launch campaign" **Platform URLs** (optional but recommended): - **iOS URL**: `https://apps.apple.com/app/your-app/id123456789` - **Android URL**: `https://play.google.com/store/apps/details?id=com.yourapp` - **Web Fallback**: `https://example.com/product/123` (desktop users) **Attribution Window:** - Select **168 hours (7 days)** - Default - Shorter windows (1-24h) for direct response campaigns - Longer windows (30-90 days) for brand awareness **UTM Parameters** (optional): - **Source**: `instagram` - **Medium**: `social` - **Campaign**: `product-launch-q1` ##### Step 4: Generate Link Click **"Create Link"** and you'll get: ``` https://lnk.forty.com/abc123 ``` Your short link is now live and ready to share. #### 5. Share Your Link Copy your short link and use it anywhere: - **Social Media**: Instagram, TikTok, Facebook, Twitter - **Email Campaigns**: Mailchimp, SendGrid, etc. - **Print Materials**: Flyers, business cards, posters - **Messaging Apps**: WhatsApp, Telegram, SMS - **Video Descriptions**: YouTube, Vimeo ##### How It Works When someone clicks your link: 1. **Web Users**: Redirected to your Original URL or Web Fallback 2. **Mobile Users (no app)**: Sent to App Store/Google Play 3. **Mobile Users (has app)**: Deep link directly into your app content 4. **All Clicks**: Tracked in real-time with full analytics #### 6. Track Your Results ##### View Analytics 1. Click **"Analytics"** in the sidebar 2. Select your link from the list 3. See real-time data: - **Total Clicks**: How many people clicked - **Installs**: How many installed your app - **Geographic Data**: Countries and cities - **Device Breakdown**: iOS, Android, Web - **Conversion Rate**: Clicks to installs ##### Export Data Export your analytics data: 1. Click **"Export"** button 2. Choose format: **JSON** or **CSV** 3. Use data in Excel, Google Sheets, or BI tools #### 7. Install Mobile SDK (Optional) To enable deferred deep linking and attribution tracking, install our SDK in your mobile app: ##### React Native ```bash npm install @linkforty/mobile-sdk-react-native ``` ```typescript import LinkForty from '@linkforty/mobile-sdk-react-native'; // Initialize at app launch (App.tsx) LinkForty.init({ baseUrl: 'https://go.yourdomain.com', appToken: 'at_your_app_token', // recommended for Cloud }); // Handle the deferred deep link on a new install LinkForty.onDeferredDeepLink((data) => { if (data) { // User installed from a short link — route to the right content navigateToContent(data); } }); ``` ##### iOS (Swift) ```bash pod 'LinkFortySDK', '~> 1.0' ``` ```swift import LinkFortySDK let config = LinkFortyConfig( baseURL: "https://go.yourdomain.com", appToken: "at_your_app_token" ) try await LinkForty.shared.initialize(config: config) LinkForty.shared.onDeferredDeepLink { deepLinkData in guard let data = deepLinkData else { return } navigateToContent(data) } ``` ##### Android (Kotlin) ```gradle implementation("com.linkforty:sdk:1.3.0") ``` ```kotlin import com.linkforty.sdk.LinkForty import com.linkforty.sdk.LinkFortyConfig val config = LinkFortyConfig( baseUrl = "https://go.yourdomain.com", appToken = "at_your_app_token" ) LinkForty.initialize(this, config) LinkForty.shared.onDeferredDeepLink { deepLinkData -> deepLinkData?.let { navigateToContent(it) } } ``` See the [SDK Integration Guide](https://docs.linkforty.com/guides/attribution/sdk-integration/) for the complete cross-platform setup, and each platform's full SDK page (including [Expo](https://docs.linkforty.com/sdks/expo/) and [Flutter](https://docs.linkforty.com/sdks/flutter/)). #### 8. Generate QR Codes Create QR codes for offline-to-online attribution: 1. Go to your link details page 2. Click **"Generate QR Code"** 3. Configure options: - **Size**: 256px, 512px, 1024px, 2048px - **Format**: PNG or SVG - **Color**: Match your brand 4. Download and use in: - Print ads - Product packaging - Event signage - Business cards #### 9. Set Up Webhooks (Optional) Get real-time notifications for clicks and installs: 1. Click **"Settings"** then **"Webhooks"** 2. Click **"Create Webhook"** 3. Enter your endpoint URL: `https://yourapp.com/webhooks/linkforty` 4. Select events: - **link.clicked** - **app.installed** - **deeplink.matched** 5. Click **"Create"** Example webhook payload: ```json { "event": "app.installed", "timestamp": "2024-01-15T10:30:00Z", "data": { "linkId": "abc123", "userId": "user_456", "platform": "ios", "country": "US", "city": "San Francisco", "utmSource": "instagram", "utmCampaign": "product-launch-q1" } } ``` Use webhooks to: - Award referral credits - Trigger email sequences - Update CRM records - Send Slack notifications #### 10. Connect AI Assistants (Optional) Manage your LinkForty workspace from Claude, Cursor, or Claude Code using natural language. The official [LinkForty MCP server](https://docs.linkforty.com/guides/advanced/mcp-server/) lets your AI assistant create links, query analytics, and even scaffold SDK integration code with prompts like: > "Show me the top 5 links from the last 30 days and which countries they're getting traffic from." > "Create a deep link for my Spring sale on Instagram with UTM source=instagram, medium=social, campaign=spring-sale." > "Help me add the LinkForty SDK to my React Native app." Quick setup for Claude Desktop, Cursor, or Claude Code — just add the URL and your API key: ```json { "mcpServers": { "linkforty": { "type": "http", "url": "https://mcp.linkforty.com/mcp", "headers": { "Authorization": "Bearer dl_your_api_key_here" } } } } ``` No local installation required. The MCP server is open source, free on every plan, and works with self-hosted LinkForty Core instances too. See the [MCP Server guide](https://docs.linkforty.com/guides/advanced/mcp-server/) for full setup instructions, all 20 available tools, stdio transport option, and project-level configuration with `.mcp.json`. --- #### Next Steps Now that you have your first link running, explore advanced features: - [Link Templates](https://docs.linkforty.com/guides/links/link-templates/) - Create reusable templates for campaigns - [UTM Parameters](https://docs.linkforty.com/guides/links/utm-parameters/) - Track campaign performance - [Advanced Analytics](https://docs.linkforty.com/guides/analytics/dashboard/) - Deep dive into metrics - [Team Collaboration](https://docs.linkforty.com/guides/team/organizations/) - Invite team members - [API Access](https://docs.linkforty.com/api/authentication/) - Automate link creation programmatically #### Getting Help - [Platform Guides](https://docs.linkforty.com/guides/links/creating-links/) - Step-by-step tutorials - [API Reference](https://docs.linkforty.com/api/authentication/) - Complete API documentation - [GitHub Discussions](https://github.com/linkforty/core/discussions) - Community support - [Report Issues](https://github.com/linkforty/core/issues) - Bug reports --- #### Pricing For current plan pricing, see the [pricing page](https://linkforty.com/pricing). **Free Tier:** - 1,000 links - 5,000 clicks/month - 3 team members - 1 custom domain - 7-day analytics retention **Pro Tier:** - 25,000 links - 100,000 clicks/month - 10 team members - 5 custom domains - 90-day analytics retention - 3 API keys (60 req/min), 5 webhooks **Business Tier:** - 100,000 links - 500,000 clicks/month - 25 team members - 15 custom domains - 180-day analytics retention - 10 API keys (200 req/min), 25 webhooks - Priority email support **Unlimited Tier:** - Unlimited links and clicks - Unlimited team members and custom domains - 365-day analytics retention - Unlimited API keys and webhooks (1,000 req/min) - Priority email + Slack support **Self-Hosted (Free):** - Deploy LinkForty Core on your infrastructure - Unlimited everything, no usage limits - Full source code access (AGPL-3.0) - See [Self-Hosting Guide](https://docs.linkforty.com/getting-started/self-hosting/) --- ## Self-Hosting Guide Source: https://docs.linkforty.com/getting-started/self-hosting/ Deploy LinkForty Core on your own infrastructure with complete control over your data. **Info: What is LinkForty Core?** This guide covers deploying **LinkForty Core** - the open-source deeplink engine. Core provides a REST API for link management, device-specific redirects, click analytics, deferred deep linking, QR codes, and webhooks. It does **not** include the Cloud dashboard, team management, or billing features. For a managed experience with a UI, see the [Quick Start](https://docs.linkforty.com/getting-started/quick-start/) for LinkForty Cloud. #### What You Get LinkForty Core is a Fastify server backed by PostgreSQL (with optional Redis caching) that provides: - **REST API** for creating, reading, updating, and deleting links - **Smart redirect routing** with device detection (iOS, Android, web) - **Click analytics** with geolocation, device type, and UTM tracking - **Deferred deep linking** and fingerprint-based attribution - **QR code generation** (PNG and SVG) - **Webhook notifications** for real-time events - **Social previews** via Open Graph tags - **iOS Universal Links** and **Android App Links** support (`.well-known` endpoints) All interactions with Core happen via its API. There is no built-in UI - you integrate it into your own application or use it as a standalone API service. --- #### Why Self-Host? **Control and Flexibility:** - No per-install fees - unlimited scaling - No vendor lock-in - Deploy anywhere (AWS, GCP, Azure, DigitalOcean, on-premise) **Data Privacy:** - Complete data ownership - GDPR/CCPA compliance on your terms - No third-party data sharing **Customization:** - Full source code access (AGPL-3.0 license) - Custom features and integrations - Modify attribution logic for your use case --- #### System Requirements ##### Minimum Specifications **Server:** - **CPU**: 2 cores - **RAM**: 4 GB - **Storage**: 20 GB SSD - **OS**: Ubuntu 20.04+ / Debian 11+ / RHEL 8+ **Software:** - Docker 20.10+ and Docker Compose 2.0+ - OR Node.js 18+ with PostgreSQL 14+ and Redis 7+ (optional) **Expected Load Capacity:** - ~1,000 requests/minute - ~100,000 links - ~1M clicks/month ##### Recommended Specifications (Production) **Server:** - **CPU**: 4 cores - **RAM**: 8 GB - **Storage**: 100 GB SSD - **OS**: Ubuntu 22.04 LTS **Expected Load Capacity:** - ~10,000 requests/minute - ~1M links - ~10M clicks/month ##### Cloud Provider Options Recommended instance types for minimum specifications: | Provider | Instance Type | Specs | |----------|--------------|-------| | **DigitalOcean** | Droplet (Basic) | 2 CPU, 4GB RAM | | **AWS** | t3.medium | 2 CPU, 4GB RAM | | **Google Cloud** | e2-medium | 2 CPU, 4GB RAM | | **Hetzner** | CX21 | 2 CPU, 4GB RAM | | **Vultr** | Regular Performance | 2 CPU, 4GB RAM | --- #### Installation Methods Choose your deployment method: - **Method 1: Docker Compose** (Recommended) - Fastest and easiest. Everything configured out of the box. - **Method 2: npm Package** - Install Core as an npm dependency in your own Node.js application. - **Method 3: Manual Installation** - For custom setups or when Docker isn't available. - **Method 4: Kubernetes** - For enterprise deployments with high availability. --- #### Method 1: Docker Compose (Recommended) ##### Step 1: Download Docker Compose File ```bash mkdir linkforty && cd linkforty curl -O https://raw.githubusercontent.com/linkforty/core/main/docker-compose.yml ``` ##### Step 2: Configure Environment Create a `.env` file: ```bash ### Database POSTGRES_DB=linkforty POSTGRES_USER=linkforty POSTGRES_PASSWORD=your-strong-password POSTGRES_PORT=5432 ### Redis (optional but recommended for caching) REDIS_URL=redis://redis:6379 REDIS_PORT=6379 ### Server NODE_ENV=production PORT=3000 HOST=0.0.0.0 LINKFORTY_PORT=3000 ### CORS - set to your frontend domain CORS_ORIGIN=* ### Optional: behind a CDN/proxy (e.g. Cloudflare), read the real client IP ### from the proxy's header so attribution stays accurate. Only enable when ### the origin is reachable ONLY through that proxy. See the env-var reference. ### TRUSTED_CLIENT_IP_HEADER=cf-connecting-ip ### Optional: JWT secret for authentication ### JWT_SECRET=your-super-secret-jwt-key ### Optional: Custom domain for short links ### SERVICE_DOMAIN=yourdomain.com ### Optional: iOS Universal Links ### IOS_TEAM_ID=ABC123XYZ ### IOS_BUNDLE_ID=com.yourcompany.yourapp ### Optional: Android App Links ### ANDROID_PACKAGE_NAME=com.yourcompany.yourapp ### ANDROID_SHA256_FINGERPRINTS=AA:BB:CC:DD:... ``` **Security Note:** Generate strong secrets with: ```bash openssl rand -hex 32 ``` ##### Step 3: Start Services ```bash docker compose up -d ``` This starts three containers: - **linkforty**: The Core Fastify API server (port 3000) - **postgres**: PostgreSQL database (port 5432) - **redis**: Redis cache (port 6379) The database schema is automatically initialized on first startup. ##### Step 4: Verify Installation ```bash ### Check all services are running docker compose ps ### Test the API curl http://localhost:3000/health ``` You should see a healthy response. The API is now available at `http://localhost:3000`. ##### Step 5: Create Your First Link ```bash curl -X POST http://localhost:3000/api/links \ -H "Content-Type: application/json" \ -d '{ "userId": "user-1", "originalUrl": "https://example.com", "title": "My First Link", "iosUrl": "https://apps.apple.com/app/your-app/id123456789", "androidUrl": "https://play.google.com/store/apps/details?id=com.yourapp", "webFallbackUrl": "https://example.com", "utmParameters": { "source": "twitter", "medium": "social", "campaign": "launch" } }' ``` The response will include a `short_code` you can use for redirects: `http://localhost:3000/{shortCode}` ##### Step 6: Set Up SSL (Required for Production) ###### Option A: Using Let's Encrypt (Free) Install Certbot and Nginx: ```bash sudo apt-get update sudo apt-get install certbot python3-certbot-nginx nginx ``` Configure Nginx as a reverse proxy: ```nginx ### /etc/nginx/sites-available/linkforty server { listen 80; server_name yourdomain.com; return 301 https://$server_name$request_uri; } server { listen 443 ssl http2; server_name yourdomain.com; ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem; # API and redirect routes location / { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Host $host; } } ``` Enable site and generate certificate: ```bash sudo ln -s /etc/nginx/sites-available/linkforty /etc/nginx/sites-enabled/ sudo certbot --nginx -d yourdomain.com sudo systemctl reload nginx ``` ###### Option B: Using Cloudflare (Free SSL + CDN) 1. Point your domain to Cloudflare nameservers 2. Enable **Full (strict)** SSL mode in Cloudflare dashboard 3. Create an origin certificate in Cloudflare 4. Install the origin certificate on your server 5. Enable **Always Use HTTPS** in Cloudflare --- #### Method 2: npm Package Install LinkForty Core as a dependency in your own Node.js application: ```bash npm install @linkforty/core ``` ##### Basic Server ```typescript import { createServer } from '@linkforty/core'; async function start() { const server = await createServer({ database: { url: 'postgresql://linkforty:password@localhost:5432/linkforty', }, redis: { url: 'redis://localhost:6379', }, }); await server.listen({ port: 3000, host: '0.0.0.0' }); console.log('LinkForty Core running on http://localhost:3000'); } start(); ``` ##### Using Individual Route Handlers You can also register only specific routes in your own Fastify application: ```typescript import Fastify from 'fastify'; import { initializeDatabase, redirectRoutes, linkRoutes, analyticsRoutes } from '@linkforty/core'; const fastify = Fastify(); // Initialize database separately await initializeDatabase({ url: 'postgresql://localhost/linkforty' }); // Register only the routes you need await fastify.register(redirectRoutes); await fastify.register(linkRoutes); await fastify.register(analyticsRoutes); await fastify.listen({ port: 3000 }); ``` This gives you maximum flexibility to integrate LinkForty into an existing application. --- #### Method 3: Manual Installation ##### Step 1: Install Dependencies ```bash ### Update system sudo apt-get update && sudo apt-get upgrade -y ### Install Node.js 18+ curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - sudo apt-get install -y nodejs ### Install PostgreSQL 14+ sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - sudo apt-get update sudo apt-get install -y postgresql-14 ### Install Redis 7+ (optional but recommended) sudo apt-get install -y redis-server ``` ##### Step 2: Configure PostgreSQL ```bash sudo -u postgres psql << EOF CREATE DATABASE linkforty; CREATE USER linkforty WITH PASSWORD 'your-strong-password'; GRANT ALL PRIVILEGES ON DATABASE linkforty TO linkforty; \q EOF ``` ##### Step 3: Clone and Build ```bash git clone https://github.com/linkforty/core.git cd core npm install npm run build ``` ##### Step 4: Configure Environment ```bash cp .env.example .env nano .env ``` Update the environment variables: ```bash DATABASE_URL=postgresql://linkforty:your-strong-password@localhost:5432/linkforty REDIS_URL=redis://localhost:6379 NODE_ENV=production PORT=3000 HOST=0.0.0.0 CORS_ORIGIN=* ``` ##### Step 5: Run Database Migrations ```bash npm run migrate ``` ##### Step 6: Set Up systemd Service Create a service file for automatic startup: ```bash sudo nano /etc/systemd/system/linkforty.service ``` ```ini [Unit] Description=LinkForty Core After=network.target postgresql.service redis.service [Service] Type=simple User=www-data WorkingDirectory=/opt/linkforty/core Environment="NODE_ENV=production" EnvironmentFile=/opt/linkforty/core/.env ExecStart=/usr/bin/node dist/index.js Restart=on-failure [Install] WantedBy=multi-user.target ``` Enable and start the service: ```bash sudo systemctl daemon-reload sudo systemctl enable linkforty sudo systemctl start linkforty ### Check status sudo systemctl status linkforty ``` --- #### Method 4: Kubernetes ##### Prerequisites - Kubernetes cluster (EKS, GKE, AKS, or self-hosted) - kubectl configured - Helm 3+ ##### Step 1: Add Helm Repository ```bash helm repo add linkforty https://charts.linkforty.com helm repo update ``` ##### Step 2: Create Values File ```yaml ### values.yaml replicaCount: 3 image: repository: linkforty/core tag: "latest" pullPolicy: IfNotPresent service: type: LoadBalancer port: 80 ingress: enabled: true className: nginx annotations: cert-manager.io/cluster-issuer: letsencrypt-prod hosts: - host: linkforty.yourdomain.com paths: - path: / pathType: Prefix tls: - secretName: linkforty-tls hosts: - linkforty.yourdomain.com postgresql: enabled: true auth: username: linkforty password: your-strong-password database: linkforty primary: persistence: size: 20Gi redis: enabled: true auth: enabled: false master: persistence: size: 8Gi env: NODE_ENV: production CORS_ORIGIN: "*" resources: limits: cpu: 1000m memory: 1Gi requests: cpu: 500m memory: 512Mi autoscaling: enabled: true minReplicas: 3 maxReplicas: 10 targetCPUUtilizationPercentage: 80 ``` ##### Step 3: Install with Helm ```bash helm install linkforty linkforty/core -f values.yaml ``` ##### Step 4: Verify Deployment ```bash kubectl get pods kubectl get services kubectl logs -l app=linkforty ``` --- #### Post-Installation Setup ##### 1. Configure Custom Domain Update DNS records to point to your server: ``` A yourdomain.com → your-server-ip ``` Then set the `SERVICE_DOMAIN` environment variable: ```bash SERVICE_DOMAIN=yourdomain.com ``` ##### 2. Configure Mobile App Links For iOS Universal Links and Android App Links, set the appropriate environment variables: ```bash ### iOS Universal Links IOS_TEAM_ID=ABC123XYZ IOS_BUNDLE_ID=com.yourcompany.yourapp ### Android App Links ANDROID_PACKAGE_NAME=com.yourcompany.yourapp ANDROID_SHA256_FINGERPRINTS=AA:BB:CC:DD:... ``` Core will automatically serve the `.well-known/apple-app-site-association` and `.well-known/assetlinks.json` files. See [iOS Universal Links](https://docs.linkforty.com/guides/attribution/sdk-integration/#ios-universal-links) and [Android App Links](https://docs.linkforty.com/guides/attribution/sdk-integration/#android-app-links) for the full setup. ##### 3. Preserve Client IP Behind a CDN If you run LinkForty behind Cloudflare, a CDN, or a load balancer, the connection IP Core sees is the proxy's — not the visitor's. Since attribution fingerprinting keys on the client IP, this hurts match accuracy. Point Core at your proxy's authoritative client-IP header: ```bash TRUSTED_CLIENT_IP_HEADER=cf-connecting-ip # Cloudflare ``` Only enable this when your origin is reachable **exclusively** through that proxy — otherwise the header can be spoofed. See the [Environment Variables reference](https://docs.linkforty.com/reference/environment-variables/#trusted-client-ip-header) for details. ##### 4. Set Up Backups ###### PostgreSQL Backup Script ```bash #!/bin/bash ### /opt/linkforty/backup.sh DATE=$(date +%Y%m%d_%H%M%S) BACKUP_DIR="/backups/linkforty" mkdir -p $BACKUP_DIR ### Backup database docker compose exec -T postgres pg_dump -U linkforty linkforty | gzip > "$BACKUP_DIR/linkforty_$DATE.sql.gz" ### Keep only last 30 days find $BACKUP_DIR -name "*.sql.gz" -mtime +30 -delete ### Upload to S3 (optional) ### aws s3 cp "$BACKUP_DIR/linkforty_$DATE.sql.gz" s3://your-backup-bucket/ ``` Add to crontab: ```bash crontab -e ### Daily backup at 2 AM 0 2 * * * /opt/linkforty/backup.sh ``` ##### 5. Set Up Monitoring ###### Using Docker Stats ```bash docker stats linkforty-linkforty-1 linkforty-postgres-1 ``` ###### Using Prometheus + Grafana ```yaml ### Add to docker-compose.yml prometheus: image: prom/prometheus volumes: - ./prometheus.yml:/etc/prometheus/prometheus.yml ports: - "9090:9090" grafana: image: grafana/grafana ports: - "3002:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=admin ``` ##### 6. Configure Firewall ```bash ### Allow HTTP/HTTPS sudo ufw allow 80/tcp sudo ufw allow 443/tcp ### Allow SSH sudo ufw allow 22/tcp ### Enable firewall sudo ufw enable ``` --- #### Updating LinkForty ##### Docker Compose ```bash cd /opt/linkforty ### Pull latest image docker compose pull ### Restart with new image docker compose up -d ``` ##### Manual Installation ```bash cd /opt/linkforty/core ### Pull latest code git pull origin main ### Update dependencies npm install ### Rebuild npm run build ### Run migrations (if needed) npm run migrate ### Restart service sudo systemctl restart linkforty ``` --- #### Troubleshooting ##### Server Won't Start ```bash ### Check logs (Docker) docker compose logs linkforty ### Check logs (systemd) sudo journalctl -u linkforty -f ### Common issues: ### 1. Database not ready - wait 10 seconds and retry ### 2. Redis not accessible - check REDIS_URL (Redis is optional, remove REDIS_URL to skip) ### 3. Port 3000 in use - change PORT in .env ``` ##### Database Connection Errors ```bash ### Test PostgreSQL connection (Docker) docker compose exec postgres psql -U linkforty -d linkforty -c "SELECT 1;" ### Check DATABASE_URL format: ### postgresql://username:password@host:port/database ``` ##### High Memory Usage ```bash ### Increase PostgreSQL shared_buffers docker compose exec postgres psql -U linkforty -d linkforty -c "ALTER SYSTEM SET shared_buffers = '256MB';" docker compose restart postgres ### Increase Node.js memory limit ### Add to environment in docker-compose.yml: ### NODE_OPTIONS: "--max-old-space-size=2048" ``` ##### Redis Connection Errors Redis is optional. If you don't need caching, remove the `REDIS_URL` environment variable and Core will fall back to direct database queries. If you do use Redis and see connection errors: ```bash ### Check Redis is running docker compose exec redis redis-cli ping ### Should return: PONG ``` --- #### Production Checklist Before going live: - SSL certificate installed and working - Strong database password set (not the default `changeme`) - Database backups configured (daily minimum) - Firewall rules configured - Monitoring set up - DNS records pointing to server - Rate limiting configured (if needed) - Test link created and redirect working - Health check endpoint responding (`/health`) - CORS configured for your frontend domain --- #### API Reference Once Core is running, all interactions happen through the REST API: | Method | Endpoint | Description | |--------|----------|-------------| | POST | `/api/links` | Create a new link | | GET | `/api/links?userId=...` | List all links for a user | | GET | `/api/links/:id?userId=...` | Get a specific link | | PUT | `/api/links/:id?userId=...` | Update a link | | DELETE | `/api/links/:id?userId=...` | Delete a link | | GET | `/api/analytics/overview?userId=...` | Analytics overview | | GET | `/api/analytics/links/:id?userId=...` | Link-specific analytics | | GET | `/:shortCode` | Redirect (public, no auth) | For full API documentation, see the [API Reference](https://docs.linkforty.com/api/authentication/). --- #### Getting Help - [API Reference](https://docs.linkforty.com/api/authentication/) - Complete API documentation - [GitHub Repository](https://github.com/linkforty/core) - Source code and releases - [Report Issues](https://github.com/linkforty/core/issues) - Bug reports - [GitHub Discussions](https://github.com/linkforty/core/discussions) - Community support #### Next Steps - [Environment Variables](https://docs.linkforty.com/reference/environment-variables/) - Full configuration reference - [API Authentication](https://docs.linkforty.com/api/authentication/) - Learn how to authenticate API requests - [Mobile SDK Integration](https://docs.linkforty.com/guides/attribution/sdk-integration/) - Set up deep linking in your app - [Webhook Configuration](https://docs.linkforty.com/guides/advanced/webhooks/) - Real-time event notifications - [Cloud vs Self-Hosted](https://docs.linkforty.com/getting-started/cloud-vs-self-hosted/) - Compare with the managed Cloud option --- ## Cloud vs Self-Hosted Source: https://docs.linkforty.com/getting-started/cloud-vs-self-hosted/ Choose the deployment option that best fits your needs. LinkForty is available in two forms: - **LinkForty Cloud** - A fully managed SaaS platform with a dashboard, team management, custom domains, and billing. Get started at [linkforty.com](https://linkforty.com). - **LinkForty Core (Self-Hosted)** - The open-source deeplink engine you deploy on your own infrastructure. It provides a REST API for all link operations but does not include a dashboard UI or team management features. Both share the same core attribution engine. The key difference is what's built on top of it. --- #### Quick Comparison | Feature | LinkForty Cloud | Self-Hosted (Core) | |---------|----------------|---------------------| | **Setup Time** | 5 minutes | 30-60 minutes | | **Monthly Cost** | Free and paid tiers — see the [pricing page](https://linkforty.com/pricing) | Your infrastructure costs only | | **Maintenance** | Zero - we handle it | You manage updates and backups | | **Dashboard UI** | Yes | No (API only) | | **Team Management** | Yes (organizations, roles, invitations) | No | | **Custom Domains** | Yes (all plans) | You configure your own domain | | **Data Location** | US data centers | Your infrastructure, any region | | **Source Code Access** | No | Yes (AGPL-3.0) | | **Support** | Email (Pro/Business) + Priority (Unlimited) + Community | Community only | --- #### LinkForty Cloud **Best For:** - Startups and small teams - Quick MVPs and prototypes - Teams without DevOps resources - Non-technical team members who need a visual interface - Testing before self-hosting ##### Pricing For current plan pricing, see the [pricing page](https://linkforty.com/pricing). ###### Free Tier - **Included**: - 1,000 links - 5,000 clicks/month - Core features (attribution, QR codes, analytics) - 1 organization, up to 3 team members - 1 custom domain - 7-day analytics retention - Community support ###### Pro Tier - **Everything in Free, plus**: - 25,000 links - 100,000 clicks/month - Up to 10 team members - 5 custom domains - 90-day analytics retention - API access (3 keys, 60 req/min) and custom branding - 5 webhooks - Email support - 99.9% uptime SLA ###### Business Tier - **Everything in Pro, plus**: - 100,000 links - 500,000 clicks/month - Up to 25 team members - 15 custom domains - 180-day analytics retention - 10 API keys (200 req/min) - 25 webhooks - Priority email support ###### Unlimited Tier - **Everything in Business, plus**: - Unlimited links and clicks - Unlimited team members and custom domains - 365-day analytics retention - Unlimited API keys and webhooks (1,000 req/min) - Priority email + Slack support ##### What Cloud Provides **Dashboard and UI** - Visual link management - create, edit, and organize links without writing code - Analytics dashboard with charts and graphs - Accessible to non-technical team members (marketers, product managers) **Team Collaboration** - Multi-organization support - Role-based access control (Owner, Admin, Member, Viewer) - Team invitations via email - Project-based link organization **Link Templates** - Reusable link configurations for campaigns - Pre-configured platform URLs, attribution windows, and UTM parameters **Custom Domains** - Use your own domain for branded short links - Cloudflare-powered with automatic SSL **Managed Infrastructure** - Automatic updates and security patches - Built-in monitoring and daily backups - Global CDN for fast redirects worldwide - DDoS protection ##### Cloud Limitations - Data stored in US data centers (cannot choose region yet) - Cannot modify platform code or add custom features - Cannot white-label or remove LinkForty branding - Usage limits on Free, Pro, and Business tiers (unlimited on Unlimited) ##### When to Choose Cloud Choose LinkForty Cloud if you: - Want to get started immediately without managing infrastructure - Need a dashboard for non-technical team members - Want team collaboration features (organizations, roles, projects) - Prefer automatic updates and maintenance - Need guaranteed uptime and support - Are okay with US data storage --- #### Self-Hosted (Core) **Best For:** - Companies with strict data residency requirements - Teams with DevOps resources - High-volume applications (millions of clicks/month) - Custom integrations and modifications - Developers who want full API control ##### What Self-Hosted Provides LinkForty Core is a Fastify API server with PostgreSQL and optional Redis. It provides the same core engine that powers Cloud, accessible entirely via REST API: - Link CRUD operations (create, read, update, delete) - Device-specific redirect routing (iOS, Android, web) - Click analytics with geolocation, device type, and UTM tracking - Deferred deep linking and fingerprint-based attribution - QR code generation (PNG and SVG) - Webhook notifications - Social media previews (Open Graph tags) - iOS Universal Links and Android App Links (`.well-known` endpoints) ##### What Self-Hosted Does Not Include These features are Cloud-only and are **not** part of Core: - Dashboard UI (all interactions are via API) - Team management (organizations, roles, invitations) - Link templates - Custom domains via Cloudflare - Billing and subscription management - Managed backups and monitoring - Email notifications ##### Infrastructure Requirements You can deploy Core on any provider that supports Docker or Node.js: | Provider | Instance Type | Approx. Cost | |----------|---------------|--------------| | **Hetzner** | CX21 (2 CPU, 4GB) | ~$5/month | | **DigitalOcean** | Droplet Basic | ~$24/month | | **AWS** | t3.medium | ~$30/month | | **Google Cloud** | e2-medium | ~$25/month | | **Vultr** | Regular Performance | ~$24/month | Additional services (optional): - **SSL**: Free with Let's Encrypt or Cloudflare - **Email** (for webhooks/notifications): SendGrid, SES, etc. - **Monitoring**: Datadog, New Relic, Prometheus + Grafana ##### Self-Hosted Advantages **Complete Control** - Full source code access (AGPL-3.0 license) - Modify any feature or add custom integrations - Modify attribution logic for your specific use case **Data Sovereignty** - Host in any region worldwide - Meet GDPR, CCPA, HIPAA requirements on your terms - Complete data ownership - no third-party access **Unlimited Everything** - No link limits - No click limits - No rate limits (unless you configure them) - Scale with your infrastructure **Predictable Costs** - No per-install fees - No overage charges - No usage-based pricing - Pay only for your infrastructure ##### Self-Hosted Limitations - No dashboard UI - all management via API or your own frontend - No team management - handle authorization in your own application - You handle all updates, backups, and monitoring - No SLA - uptime depends on your infrastructure - Initial setup requires 30-60 minutes and basic server administration knowledge ##### When to Choose Self-Hosted Choose Self-Hosted if you: - Have DevOps or infrastructure team - Need data stored in a specific region - Want to modify source code or add custom features - Need unlimited scale without usage-based pricing - Process high volumes of clicks - Have strict compliance requirements (HIPAA, GDPR, data residency) - Want complete data ownership and no vendor dependency --- #### Feature Comparison Matrix | Feature | Cloud (Free) | Cloud (Pro) | Cloud (Business) | Cloud (Unlimited) | Self-Hosted (Core) | |---------|-------------|-------------|-----------------|-------------------|---------------------| | **Links** | | | | | | | Short links | 1,000 | 25,000 | 100,000 | Unlimited | Unlimited | | Custom short codes | Yes | Yes | Yes | Yes | Yes | | Link expiration | Yes | Yes | Yes | Yes | Yes | | Click-ID passthrough (`lf_click`) | On by default | On by default | On by default | On by default | Off by default | | Targeting rules | Yes | Yes | Yes | Yes | Yes | | Link templates | Yes | Yes | Yes | Yes | No | | **Attribution** | | | | | | | Deferred deep linking | Yes | Yes | Yes | Yes | Yes | | Fingerprint matching | Yes | Yes | Yes | Yes | Yes | | Attribution windows (1-2160h) | Yes | Yes | Yes | Yes | Yes | | Click tracking | 5K/month | 100K/month | 500K/month | Unlimited | Unlimited | | Install attribution | Yes | Yes | Yes | Yes | Yes | | **QR Codes** | | | | | | | QR code generation | Yes | Yes | Yes | Yes | Yes | | Custom colors | Yes | Yes | Yes | Yes | Yes | | Multiple formats (PNG, SVG) | Yes | Yes | Yes | Yes | Yes | | Custom sizes | Yes | Yes | Yes | Yes | Yes | | **Analytics** | | | | | | | Click analytics | Yes | Yes | Yes | Yes | Yes | | Geographic data | Yes | Yes | Yes | Yes | Yes | | Device breakdown | Yes | Yes | Yes | Yes | Yes | | Analytics retention | 7 days | 90 days | 180 days | 365 days | Unlimited | | Data export (JSON/CSV) | Basic | Advanced | Full | Full | Full (via API) | | Analytics dashboard (UI) | Yes | Yes | Yes | Yes | No (API only) | | **Team and Organization** | | | | | | | Organizations | 1 | Unlimited | Unlimited | Unlimited | N/A | | Team members | 3 | 10 | 25 | Unlimited | N/A | | Role-based access | Yes | Yes | Yes | Yes | N/A | | Projects | Yes | Yes | Yes | Yes | N/A | | **Advanced Features** | | | | | | | Custom domains | 1 | 5 | 15 | Unlimited | You configure | | API keys | No | 3 | 10 | Unlimited | N/A | | Webhooks | No | 5 | 25 | Unlimited | Yes | | API rate limit | No API | 60/min | 200/min | 1,000/min | Configurable | | REST API | No | Yes | Yes | Yes | Yes | | UTM parameters | Yes | Yes | Yes | Yes | Yes | | Social previews (OG tags) | Yes | Yes | Yes | Yes | Yes | | **Mobile SDKs** | Yes | Yes | Yes | Yes | Yes | | **Platform** | | | | | | | Dashboard UI | Yes | Yes | Yes | Yes | No | | Managed backups | Yes | Yes | Yes | Yes | You configure | | Automatic updates | Yes | Yes | Yes | Yes | Manual | | **Customization** | | | | | | | Source code access | No | No | No | No | Yes (AGPL-3.0) | | Custom features | No | No | No | No | Yes | | **Support** | | | | | | | Community support | Yes | Yes | Yes | Yes | Yes | | Email support | No | Yes | Yes | Yes | No | | Priority support | No | No | Yes (email) | Yes (email + Slack) | No | | **Compliance** | | | | | | | Data residency | US | US | US | US | Any region | | **Reliability** | | | | | | | Uptime SLA | - | 99.9% | 99.9% | 99.9% | Your setup | --- #### Migration Path ##### From Cloud to Self-Hosted You can migrate from Cloud to Self-Hosted at any time: **Step 1: Export Your Data** From the Cloud dashboard: Settings, then Data Export, then Export All Data This downloads all your links, analytics, and configurations. **Step 2: Set Up Self-Hosted Instance** Follow the [Self-Hosting Guide](https://docs.linkforty.com/getting-started/self-hosting/). **Step 3: Import Data** ```bash ### Using the Core API, import your links via the REST API ### or restore from the exported data ``` **Step 4: Update DNS** Point your domain to your self-hosted server. ##### From Self-Hosted to Cloud **Step 1: Export Your Database** ```bash pg_dump -U linkforty linkforty > linkforty-backup.sql ``` **Step 2: Sign Up for Cloud** Create an account at [linkforty.com](https://linkforty.com). **Step 3: Contact Support** Email inbox@linkforty.com with your database export and we'll import your data (usually within 24 hours). **Step 4: Update DNS** Point your domain to Cloud servers. --- #### Hybrid Approach You can use both Cloud and Self-Hosted simultaneously: **Multi-Region Deployment** - **Cloud**: US customers (low latency, zero maintenance) - **Self-Hosted (EU)**: European customers (GDPR compliance) **Development and Production** - **Cloud**: Production environment (zero maintenance) - **Self-Hosted**: Development and staging (full control) --- #### Decision Matrix Answer these questions to choose: **1. Do you have a DevOps or infrastructure team?** - Yes - Consider Self-Hosted - No - Cloud **2. Do non-technical team members need to manage links?** - Yes - Cloud (provides a dashboard UI) - No - Either option works **3. How many clicks per month?** - Under 5K - Cloud (Free) - 5K-100K - Cloud (Pro) or Self-Hosted - 100K-500K - Cloud (Business) or Self-Hosted - 500K+ - Cloud (Unlimited) or Self-Hosted **4. Data residency requirements?** - Must be in a specific region - Self-Hosted - US is fine - Cloud **5. Need to modify source code?** - Yes - Self-Hosted - No - Cloud **6. Need team collaboration (organizations, roles)?** - Yes - Cloud - No - Either option works --- #### Frequently Asked Questions ##### Can I try both? Yes. Start with Cloud (5 minutes setup) and migrate to Self-Hosted later if needed. Both use the same core engine, so your links and analytics data can be migrated. ##### Is data export available? Yes. Cloud provides full data export (JSON and CSV) from the dashboard. Self-Hosted gives you direct database access. ##### Can I use my own domain on Cloud? Yes, custom domains are available on all Cloud plans. The Free tier includes 1 custom domain; Pro includes 5; Business includes 15; Unlimited includes unlimited. ##### What about support? - **Cloud Pro**: Email support + community - **Cloud Business**: Priority email support + community - **Cloud Unlimited**: Priority email + Slack support + community - **Self-Hosted**: Community support only ([GitHub Discussions](https://github.com/linkforty/core/discussions)) ##### How do updates work? - **Cloud**: Automatic, zero downtime - **Self-Hosted**: Pull the latest Docker image or git changes and restart (typically takes a few minutes) ##### Can I switch between Cloud and Self-Hosted? Yes, migration is supported in both directions. See the Migration Path section above. ##### Does Self-Hosted include a dashboard? No. LinkForty Core is an API-only service. You interact with it via the REST API. If you need a visual interface, choose Cloud or build your own frontend on top of the Core API. --- #### Getting Started **Try LinkForty Cloud:** - [Sign up for free](https://linkforty.com) - no credit card required - [Quick Start Guide](https://docs.linkforty.com/getting-started/quick-start/) **Deploy Self-Hosted:** - [Self-Hosting Guide](https://docs.linkforty.com/getting-started/self-hosting/) - [GitHub Repository](https://github.com/linkforty/core) Still have questions? - [GitHub Discussions](https://github.com/linkforty/core/discussions) - Community support - Email: inbox@linkforty.com --- # Deployment ## Production Deployment Checklist Source: https://docs.linkforty.com/deployment/production-checklist/ Use this checklist to ensure your LinkForty deployment is production-ready, secure, and reliable. **Info: Core vs Cloud** This checklist primarily applies to **self-hosted LinkForty Core** deployments. If you're using **LinkForty Cloud**, infrastructure, security, backups, and monitoring are handled for you - see the [Quick Start](https://docs.linkforty.com/getting-started/quick-start/) to get started. --- #### Pre-Deployment Planning ##### Infrastructure Requirements - [ ] **Server sizing** - Provision appropriate resources - Minimum: 2 CPU cores, 4 GB RAM, 20 GB SSD - Recommended: 4 CPU cores, 8 GB RAM, 100 GB SSD - See [Self-Hosting Guide](https://docs.linkforty.com/getting-started/self-hosting/) for provider recommendations - [ ] **PostgreSQL sizing** - Calculate database requirements - Estimate: ~1 GB storage per 1M click events - Recommended: 4 GB+ RAM for PostgreSQL - Plan for growth: 20-30% buffer capacity - [ ] **Redis capacity** (optional but recommended) - Plan cache memory - Minimum: 512 MB RAM - Recommended: 2 GB+ for high-traffic deployments - Enable persistence (RDB or AOF) if you want cache to survive restarts - [ ] **Network bandwidth** - Estimate requirements - Typical: 100-500 requests/second = 10-50 Mbps - Factor in: redirect traffic, API calls, webhook deliveries, SDK traffic ##### Domain and DNS - [ ] **Custom domain** - Configure your domain for short links - Example: `go.yourdomain.com` or `links.yourdomain.com` - Set up A record pointing to your server - [ ] **SSL certificate** - Obtain and configure HTTPS - Use Let's Encrypt (free) or a commercial certificate - Configure automatic renewal - Enforce HTTPS redirects via Nginx or Cloudflare - [ ] **DNS propagation** - Allow time for DNS changes - Test from multiple locations - Verify SSL certificate validity --- #### Security Hardening ##### Environment Variables - [ ] **Change default credentials** - Replace all default values - `POSTGRES_PASSWORD` - Use a strong, unique password (not `changeme`) - `JWT_SECRET` (if enabled) - Generate with `openssl rand -hex 32` - Never commit `.env` files to version control - [ ] **Database credentials** - Secure PostgreSQL access - Create a dedicated application user with limited privileges - Use connection encryption (SSL/TLS) in production - Restrict connections to application servers only - [ ] **Redis security** (if using Redis) - Protect your cache - Set `requirepass` for authentication - Bind to localhost or private network only - Disable dangerous commands (`FLUSHALL`, `KEYS`, etc.) ##### Network Security - [ ] **Firewall rules** - Restrict network access - Allow only ports 80, 443, and SSH - Block public access to PostgreSQL (5432) and Redis (6379) - Restrict database access to application servers only - [ ] **CORS configuration** - Set allowed origins - Set `CORS_ORIGIN` to your specific domain(s) instead of `*` - Restrict API access to known origins in production - [ ] **Rate limiting** (optional) - Protect against abuse - Set `RATE_LIMIT_ENABLED=true` - Configure `RATE_LIMIT_MAX` (recommended: 100 requests per window) - Configure `RATE_LIMIT_WINDOW` (recommended: 60000ms / 1 minute) - Rate limiting uses Redis for distributed tracking when available --- #### Database Setup ##### PostgreSQL Configuration - [ ] **Schema initialization** - Verify database is ready - Core auto-initializes the schema on first startup - Or run manually: `npm run migrate` (from the Core directory) - [ ] **Connection pooling** - Review pool settings - Default: min 2, max 10 connections - Adjust based on your traffic and server resources - Monitor pool usage and connection wait times - [ ] **Performance tuning** - Optimize PostgreSQL for production - `shared_buffers` - Set to ~25% of available RAM - `work_mem` - Set appropriately for your query complexity - `max_connections` - Match to expected connection count - Enable slow query logging (`log_min_duration_statement = 1000`) - [ ] **Backup configuration** - Set up automated backups - Daily full backups via `pg_dump` - Consider WAL archiving for point-in-time recovery - Store backups off-site (S3, separate server, etc.) - Test restore procedures regularly ##### Data Retention - [ ] **Retention policies** - Define how long to keep data - Click events: decide on retention period (e.g., 90 days, 1 year, indefinite) - Set up periodic cleanup of old click events if needed - Archive old data to cold storage for compliance if required --- #### Application Configuration ##### Environment Setup - [ ] **Production mode** - Set Node.js environment ```bash NODE_ENV=production ``` - [ ] **Host binding** - Bind to all interfaces for external access ```bash HOST=0.0.0.0 PORT=3000 ``` - [ ] **Mobile app links** (if using mobile SDKs) - Configure `.well-known` endpoints - Set `IOS_TEAM_ID` and `IOS_BUNDLE_ID` for Universal Links - Set `ANDROID_PACKAGE_NAME` and `ANDROID_SHA256_FINGERPRINTS` for App Links - Verify endpoints: `curl https://yourdomain.com/.well-known/apple-app-site-association` ##### Process Management - [ ] **Process manager** - Use systemd, PM2, or Docker restart policies - Docker: `restart: unless-stopped` (already set in Core's docker-compose.yml) - systemd: See the [Self-Hosting Guide](https://docs.linkforty.com/getting-started/self-hosting/) for service file example - PM2: `pm2 start dist/index.js --name linkforty` - [ ] **Restart policies** - Configure automatic recovery - Restart on failure with backoff delay - Set max restart attempts to prevent crash loops - [ ] **Health checks** - Verify the server is responding - Core exposes a `/health` endpoint - Configure your load balancer or monitoring to check it regularly --- #### Monitoring and Logging ##### Application Monitoring - [ ] **Error tracking** - Set up error monitoring - Use Sentry, Bugsnag, or similar service - Monitor error rates and types - Set up alerts for critical errors - [ ] **Performance monitoring** - Track response times - Monitor API response times and redirect latency - Track database query performance - Monitor memory and CPU usage - Set up alerts for anomalies - [ ] **Uptime monitoring** - Monitor availability - Use UptimeRobot, Pingdom, Better Stack, or similar - Check the `/health` endpoint every 1-5 minutes - Alert on downtime immediately ##### Logging - [ ] **Structured logging** - Fastify logs in JSON format by default - Log levels: ERROR, WARN, INFO, DEBUG - Use `NODE_ENV=production` for appropriate log levels - [ ] **Log rotation** - Prevent disk space issues - Rotate logs daily or by size (100 MB) - Keep 7-30 days of logs - Compress old logs - [ ] **Log aggregation** (optional) - Centralize logs for searching - Use ELK Stack, Datadog, Loki + Grafana, or similar - Enable log searching and filtering - Set up log-based alerts for error patterns ##### Key Metrics to Track - [ ] **Business metrics** - Redirects per minute/hour/day - Active links count - API requests per second - Error rates (4xx and 5xx) - Average redirect latency - [ ] **Infrastructure metrics** - CPU and memory usage - Disk I/O and free space - Network traffic - PostgreSQL connection count and query times - Redis memory usage and hit rate (if using Redis) --- #### Pre-Launch Testing - [ ] **Staging environment** - Test in a production-like environment first - Mirror production configuration - Run through the smoke test checklist below - Test backup and restore procedures - [ ] **Load testing** - Verify performance under expected traffic - Simulate expected traffic (aim for 2x peak) - Monitor resource usage during the test - Identify bottlenecks before they hit production - [ ] **Security scan** - Run security checks - `npm audit` for dependency vulnerabilities - SSL/TLS configuration test (ssllabs.com) - Verify firewall rules block unauthorized access --- #### Deployment Smoke Tests After deploying, verify these core functions work: - [ ] **Health check** - Server responds ```bash curl https://yourdomain.com/health ``` - [ ] **Create a link** - API is functional ```bash curl -X POST https://yourdomain.com/api/links \ -H "Content-Type: application/json" \ -d '{"userId":"test","originalUrl":"https://example.com","title":"Test Link"}' ``` - [ ] **Redirect works** - Short link resolves correctly ```bash curl -I https://yourdomain.com/{shortCode} # Should return 302 redirect ``` - [ ] **Analytics recorded** - Click event was tracked ```bash curl "https://yourdomain.com/api/analytics/links/{linkId}?userId=test" ``` - [ ] **SSL valid** - HTTPS is working correctly - No mixed content warnings - Certificate is valid and not expiring soon - [ ] **Mobile app links** (if configured) - `.well-known` endpoints respond ```bash curl https://yourdomain.com/.well-known/apple-app-site-association curl https://yourdomain.com/.well-known/assetlinks.json ``` --- #### Backup and Disaster Recovery ##### Backup Strategy - [ ] **Database backups** - Automated and tested - Daily full backups (minimum) - Off-site backup storage - 30-day retention minimum - See the [Self-Hosting Guide](https://docs.linkforty.com/getting-started/self-hosting/) for a backup script example - [ ] **Configuration backups** - Save all configuration - `.env` file (store securely, not in version control) - Nginx configuration - SSL certificates (or ensure auto-renewal works) - Docker Compose file and any customizations - [ ] **Test restores** - Verify backups actually work - Perform a test restore at least monthly - Document the restore procedure - Measure restore time ##### Disaster Recovery - [ ] **Recovery plan** - Document what to do when things break - Step-by-step recovery procedures for common failures - Define acceptable data loss (RPO) and downtime (RTO) - Assign responsibilities --- #### Compliance and Privacy - [ ] **Data privacy** - Review your obligations - Update your privacy policy to reflect attribution data collection - Implement data export and deletion procedures if required (GDPR, CCPA) - Define and document data retention periods - [ ] **IP geolocation data** - Understand what's collected - Core tracks IP addresses, geolocation, and user agents for click analytics - Ensure this aligns with your privacy policy - Consider IP anonymization if required by your jurisdiction --- #### Post-Launch - [ ] **Monitor closely** for the first 48 hours - Watch error rates, response times, and resource usage - Be ready to roll back if issues arise - [ ] **Document learnings** - Update your runbooks - Note any issues encountered during deployment - Update procedures based on real experience - [ ] **Schedule regular maintenance** - Monthly: review metrics, update dependencies, verify backups - Quarterly: security audit, performance review, capacity planning --- #### Getting Help - [Self-Hosting Guide](https://docs.linkforty.com/getting-started/self-hosting/) - Complete deployment instructions - [GitHub Discussions](https://github.com/linkforty/core/discussions) - Community support - [Report Issues](https://github.com/linkforty/core/issues) - Bug reports --- # Links ## Creating Links Source: https://docs.linkforty.com/guides/links/creating-links/ Learn how to create powerful short links with platform-specific routing, attribution tracking, and UTM parameters. #### Overview LinkForty links are more than just URL shorteners. Each link can: - Route users to different destinations based on their device (iOS, Android, Web) - Track clicks and app installs with configurable attribution windows - Include UTM parameters for campaign tracking - Apply targeting rules (geographic, language, device) - Generate QR codes for offline-to-online attribution - Include social preview tags for rich link sharing - Be served from [your own branded domain](https://docs.linkforty.com/guides/links/custom-domains/) instead of the shared LinkForty domain **Info: Prerequisites** **Before creating links, you must first create a template.** Templates define default settings for your links, such as domain, UTM parameters, and platform-specific URLs. See [Link Templates](https://docs.linkforty.com/guides/links/link-templates/) to create your first template. #### Creating Your First Link ##### Via Dashboard **Step 1: Navigate to Links Page** 1. Log in to your LinkForty dashboard 2. Click **"Links"** in the left sidebar 3. Click the **"Create Link"** button (top right) **Step 2: Select a Template** **Template** (required) ``` Select from dropdown: e.g., "Default Template" or "Instagram Campaign" ``` **Tip: Template Auto-Fill** When you select a template, its default settings will automatically pre-populate the form fields in the Advanced Options section. You can use these defaults as-is, or override any value you want to customize for this specific link. Templates provide default settings for your link, including: - **Platform URLs** - iOS App Store, Android Play Store, and Web fallback URLs - **Attribution Window** - How long clicks remain valid for attribution - **UTM Parameters** - Campaign tracking parameters - **Targeting Rules** - Geographic, language, and device targeting **Step 3: Fill in Basic Information** **Original URL** (required) ``` https://example.com/products/wireless-headphones ``` This is where users will be sent if no platform-specific URL matches. **Title** (optional, recommended) ``` Wireless Headphones - Spring Sale ``` Internal reference for your team. Not visible to users. **Description** (optional) ``` Q2 2024 Instagram campaign for new wireless headphones launch ``` Helps organize and search for links later. **Step 4: Configure Platform-Specific URLs** Route users based on their device. **Info: Template Defaults** If your selected template has default platform URLs configured, these fields will be automatically filled. You can leave them as-is to use the template defaults, or enter different values to override them for this specific link. **iOS URL** (optional) ``` https://apps.apple.com/app/your-store/id123456789 ``` When clicked on iPhone/iPad, users see App Store. **Android URL** (optional) ``` https://play.google.com/store/apps/details?id=com.yourstore ``` When clicked on Android, users see Google Play. **Web Fallback URL** (optional) ``` https://example.com/products/wireless-headphones?platform=web ``` Destination for desktop/laptop users. Falls back to Original URL if empty. **Step 5: Set Attribution Window** Choose how long clicks remain valid for attribution: | Window | Use Case | Conversion Rate | |--------|----------|-----------------| | **1 hour** | Immediate actions, privacy-focused | 5-10% | | **24 hours** | Direct response campaigns | 15-25% | | **72 hours** | Short promotional campaigns | 25-35% | | **168 hours (7 days)** | Industry standard, balanced | 35-50% | | **336 hours (14 days)** | Retargeting campaigns | 40-55% | | **720 hours (30 days)** | Brand awareness | 45-60% | | **2160 hours (90 days)** | Long consideration cycles | 50-70% | **Recommendation:** Start with **7 days (168 hours)** for most campaigns. **How it works:** - User clicks your link at 10:00 AM on Monday - Link creates a fingerprint and stores it - User installs your app at 3:00 PM on Wednesday (65 hours later) - SDK checks for matching fingerprint within attribution window - If match found (65h < 168h), install is attributed to your link **Step 6: Add UTM Parameters** (optional) Track campaign performance: ```json { "utm_source": "instagram", "utm_medium": "social", "utm_campaign": "spring-sale-2024", "utm_term": "wireless-headphones", "utm_content": "carousel-ad-1" } ``` These parameters are automatically appended to your destination URLs: ``` https://example.com/products/wireless-headphones?utm_source=instagram&utm_medium=social&utm_campaign=spring-sale-2024 ``` **Step 7: Custom Short Code** (optional) By default, LinkForty generates random codes like `abc123`. You can customize: ``` Custom Code: spring-sale Generated Link: https://lnk.forty.com/spring-sale ``` **Requirements:** - 4-30 characters - Alphanumeric only (a-z, A-Z, 0-9, hyphens, underscores) - Must be unique **Step 8: Set Expiration** (optional) Make links expire after a specific date: ``` Expires At: 2024-03-31 23:59:59 ``` After expiration the link stops resolving. See [Link Settings](#link-settings) for exactly what a visitor sees. **Step 9: Create Link** Click **"Create Link"** button. Your link is instantly live: ``` https://lnk.forty.com/abc123 ``` --- #### Creating Links via API ##### Authentication Get your API key from Settings → API Keys. ```bash export LINKFORTY_API_KEY="your-api-key-here" ``` ##### Basic Link Creation ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/product/123", "title": "Product 123 Campaign", "iosUrl": "https://apps.apple.com/app/id123456789", "androidUrl": "https://play.google.com/store/apps/details?id=com.app", "attributionWindowHours": 168, "utmParameters": { "source": "facebook", "medium": "cpc", "campaign": "summer-2024" } }' ``` **Response:** ```json { "id": "link_abc123xyz", "short_code": "abc123", "short_url": "https://lnk.forty.com/abc123", "original_url": "https://example.com/product/123", "ios_url": "https://apps.apple.com/app/id123456789", "android_url": "https://play.google.com/store/apps/details?id=com.app", "attribution_window_hours": 168, "utm_parameters": { "source": "facebook", "medium": "cpc", "campaign": "summer-2024" }, "created_at": "2024-01-15T10:30:00Z", "is_active": true } ``` ##### With Custom Short Code ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/sale", "customCode": "summer-sale", "attributionWindowHours": 336 }' ``` Generates: `https://lnk.forty.com/summer-sale` ##### With Targeting Rules ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/product/123", "targetingRules": { "countries": ["US", "CA", "GB"], "languages": ["en"], "devices": ["ios", "android"] } }' ``` ##### With Expiration ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/flash-sale", "expiresAt": "2024-12-31T23:59:59Z" }' ``` --- #### Using Link Templates Templates speed up link creation by pre-filling common settings. ##### Creating a Template **Via Dashboard:** 1. Go to **Settings** → **Link Templates** 2. Click **"Create Template"** 3. Fill in defaults: - iOS URL: `https://apps.apple.com/app/id123456789` - Android URL: `https://play.google.com/store/apps/details?id=com.app` - Attribution Window: 168 hours - UTM Medium: `social` 4. Click **"Save Template"** **Via API:** ```bash curl -X POST https://api.linkforty.com/api/templates \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Social Media Default", "iosUrl": "https://apps.apple.com/app/id123456789", "androidUrl": "https://play.google.com/store/apps/details?id=com.app", "attributionWindowHours": 168, "utmParameters": { "medium": "social" } }' ``` ##### Using a Template When creating links, select your template. Only fill in unique fields: ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/new-product", "utmParameters": { "source": "instagram", "campaign": "launch-week" } }' ``` Template fills: `iosUrl`, `androidUrl`, `attributionWindowHours`, `utm_medium: "social"` You override: `utm_source`, `utm_campaign` See [Link Templates Guide](https://docs.linkforty.com/guides/links/link-templates/) for more details. --- #### Best Practices ##### 1. Always Use Platform-Specific URLs **Bad:** ```json { "originalUrl": "https://example.com/product" } ``` **Good:** ```json { "originalUrl": "https://example.com/product", "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.app", "webFallbackUrl": "https://example.com/product?platform=web" } ``` **Why:** Improves attribution accuracy by 3-5x. Users on mobile without app installed go to store instead of dead-end web page. ##### 2. Use Descriptive Titles **Bad:** ``` Title: Link 1 ``` **Good:** ``` Title: Instagram Story - Summer Sale - Wireless Headphones ``` **Why:** Easier to find links later. Team knows what campaign it's for. ##### 3. Match Attribution Window to Campaign Type | Campaign Type | Recommended Window | |---------------|-------------------| | Flash sales (24h) | 24-72 hours | | Product launches | 7-14 days | | Brand awareness | 14-30 days | | Referral programs | 30-90 days | | Content marketing | 7-30 days | | Retargeting | 14-30 days | ##### 4. Consistent UTM Naming Create a naming convention and stick to it: ```json { "utm_source": "instagram", // lowercase, platform name "utm_medium": "social", // lowercase, channel type "utm_campaign": "spring-2024", // lowercase-with-dashes, semantic "utm_term": "headphones", // lowercase, product category "utm_content": "carousel-1" // lowercase-with-dashes, creative variant } ``` ##### 5. Use Custom Codes for Memorable Links **For marketing materials:** ``` https://lnk.forty.com/spring-sale ``` **For print/QR codes:** ``` https://lnk.forty.com/menu ``` **For referrals:** ``` https://lnk.forty.com/john-referral ``` ##### 6. Set Expiration for Limited Campaigns Always set expiration for: - Flash sales - Event promotions - Limited-time offers - Seasonal campaigns **Example:** ```json { "title": "Black Friday 2024", "expiresAt": "2024-11-30T23:59:59Z" } ``` --- #### Link Settings Three per-link settings that apply after the link is created, and behave in ways that are easy to be surprised by. ##### Expiration Date Set `expiresAt` to have a link stop working after a given moment: ```bash curl -X PATCH https://api.linkforty.com/api/links/link_abc123xyz \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"expiresAt": "2026-03-31T23:59:59Z"}' ``` To remove an expiration date, send `null` explicitly — omitting the field leaves the existing date in place: ```json { "expiresAt": null } ``` **What a visitor sees.** An expired link responds exactly as though it never existed: `404`, with no indication that it was ever a real link. There is no "this link has expired" page. That is deliberate — an expired-link page would confirm to anyone probing short codes that a given code was once valid. **Info: Expiry is applied hourly, not to the second** A job runs at the top of every hour and deactivates links whose expiry has passed. A link can therefore keep resolving for up to an hour after the timestamp you set. Set the expiry an hour early if the cut-off has to be exact. ##### Click-ID Passthrough When enabled, the redirect appends an `lf_click` parameter carrying the ID of that specific click to the destination URL: ``` Destination configured: https://example.com/spring-sale Visitor is sent to: https://example.com/spring-sale?lf_click=3f9a1c2e-... ``` That value is the primary key of the click event in your analytics. Handing it to the landing page lets a downstream tool — your own analytics, a data warehouse, a CRM — join a session on your site back to the exact click that produced it, rather than guessing from timestamps. **Only web destinations are touched.** The parameter is added to HTTP and HTTPS destinations only. A custom-scheme destination (`myapp://…`) is left exactly as configured, as is any destination that cannot be parsed as an absolute URL. **Caution: `lf_click` is a reserved parameter name** Do not use `lf_click` for your own purposes on a destination URL. When passthrough is on, LinkForty sets this key and will overwrite a value you put there yourself. **Turn it off for signature-sensitive destinations.** Anything that validates its own query string — S3 presigned URLs, signed CDN links, payment callbacks — will reject the request once an extra parameter is appended. Set `appendClickId` to `false` on those links: ```json { "appendClickId": false } ``` **The default differs between Cloud and self-hosted:** | Deployment | Default | |---|---| | LinkForty Cloud | **On** — new links append `lf_click` unless you opt out | | Self-hosted (Core) | **Off** — opt in per link | If you run both, do not assume a link behaves the same way in each. ##### Custom Domain A link can be issued on any verified domain in your workspace rather than the default one, chosen when you create or edit it. See [Custom Domains](https://docs.linkforty.com/guides/links/custom-domains/) for adding and verifying them. --- #### Advanced Features ##### Deep Link Parameters Pass custom data to your app after install: ```json { "originalUrl": "https://example.com/product/123?deeplink_param=value", "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.app" } ``` After user installs and opens app: ```typescript const deepLinkData = await LinkForty.getInstallData(); // Returns: { deeplink_param: "value" } // Navigate to specific product navigateToProduct(deepLinkData.deeplink_param); ``` ##### Social Preview Tags Add Open Graph tags for rich previews. These are per-link overrides — they take priority over your workspace's [share card](https://docs.linkforty.com/guides/links/social-previews/), so set them only when this specific link needs a different card: ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/product/123", "socialPreview": { "title": "Wireless Headphones - 50% Off", "description": "Premium noise-cancelling headphones. Limited time offer.", "imageUrl": "https://example.com/images/headphones-og.jpg" } }' ``` When shared on social media, shows: - Image preview - Custom title - Custom description See the [Create Link API reference](https://docs.linkforty.com/api/endpoints/links/create-link/) for all available social preview fields. --- #### Troubleshooting ##### Link Returns 404 **Cause:** Short code doesn't exist or link was deleted. **Solution:** 1. Check if link exists: GET `/api/links/:shortCode` 2. Verify link is active: `is_active: true` 3. Check expiration: `expires_at` is null or future date ##### Link Not Redirecting to Correct Platform **Cause:** Platform detection issue. **Solution:** 1. Test user-agent detection: ```bash curl -A "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)" \ https://lnk.forty.com/abc123 ``` 2. Verify iOS/Android URLs are set 3. Check targeting rules aren't blocking ##### Attribution Not Working **Cause:** SDK not initialized or fingerprint mismatch. **Solution:** 1. Verify SDK is initialized: `LinkForty.init()` 2. Check attribution window hasn't expired 3. Confirm user clicked link before installing 4. Test on real device (simulators may fail fingerprint matching) See the [Deferred Deep Linking guide](https://docs.linkforty.com/guides/attribution/deferred-deep-linking/) for troubleshooting fingerprint matching issues. --- #### Next Steps - [Link Templates](https://docs.linkforty.com/guides/links/link-templates/) - Create reusable templates - [Bulk Operations](https://docs.linkforty.com/api/endpoints/links/bulk-operations/) - Create and manage links in bulk via API - [QR Codes](https://docs.linkforty.com/guides/links/qr-codes/) - Generate QR codes for offline attribution - [SDK Integration](https://docs.linkforty.com/guides/attribution/sdk-integration/) - Set up mobile SDK - [Analytics](https://docs.linkforty.com/guides/analytics/dashboard/) - Track link performance #### API Reference - [Create Link](https://docs.linkforty.com/api/endpoints/links/create-link/) - [Get Link](https://docs.linkforty.com/api/endpoints/links/get-link/) - [Update Link](https://docs.linkforty.com/api/endpoints/links/update-link/) - [Delete Link](https://docs.linkforty.com/api/endpoints/links/delete-link/) - [Bulk Operations](https://docs.linkforty.com/api/endpoints/links/bulk-operations/) --- ## Custom Domains Source: https://docs.linkforty.com/guides/links/custom-domains/ By default your links are served from LinkForty's shared domain. A custom domain serves them from your own instead: ``` https://go.linkforty.com/summer-sale → https://links.example.com/summer-sale ``` #### Why It Matters Branding is the obvious reason, but for deep links there is a second one that matters more. Universal Links (iOS) and App Links (Android) only open your app when the link's domain is associated with your app. That association lives on a domain you control. On a shared domain, every workspace's links come from the same host — on your own domain, your app owns the association, and the association files are served automatically for you. There is also a deliverability argument. A shared link domain accumulates whatever reputation all of its users earn. Your own domain earns only yours. #### Plan Limits | Plan | Custom domains | |---|---| | Free | 1 | | Pro | 5 | | Business | 15 | | Unlimited | Unlimited | Adding a domain beyond your plan's limit is rejected until you remove one or upgrade. #### Who Can Manage Domains | Action | Required role | |---|---| | View the domain list | Any member with settings access | | Add, verify, remove, or set a default | **Owner** | This is stricter than most settings in LinkForty. A custom domain changes where every existing link resolves, so it is deliberately limited to workspace owners. #### Adding a Domain **Step 1: Open the domains settings** Go to **Settings → Custom Domains**. **Step 2: Add the domain** Click **Add Domain** and enter the hostname on its own — no protocol, no path, no trailing slash: ``` links.example.com ``` **Tip: Use a subdomain** Point a subdomain such as `links.` or `go.` at LinkForty rather than your root domain. A root domain usually already serves your website, and a CNAME on it will collide with that. **Step 3: Add the CNAME record** When the domain is accepted, the dashboard shows the exact DNS record to create. Add it at whatever DNS provider hosts the domain — Cloudflare, Route 53, Namecheap, and so on: | Type | Name | Value | |---|---|---| | `CNAME` | the subdomain, e.g. `links` | the fallback origin shown in the dashboard | **Info: No TXT record needed** LinkForty verifies ownership over HTTP, not DNS TXT. The CNAME is the only record you create. Once it resolves, verification and SSL issuance both happen on their own. **Step 4: Verify** Click **Verify** on the domain. It becomes usable only when **both** ownership and the SSL certificate are active — the dashboard reports which of the two is still outstanding. Most domains finish in **5–15 minutes**. DNS can take up to **24 hours** to propagate, so a domain that is not ready immediately is normal rather than broken. Click Verify again after a few minutes. #### Setting a Default Domain Once a domain is verified you can mark it as the workspace default, and new links use it automatically. A domain must finish verification before it can become the default — this prevents pointing new links at a hostname that does not resolve yet. Setting a new default clears the previous one; exactly one domain is the default at a time. #### Using a Specific Domain Per Link The default is not a lock. Any individual link can be issued on any verified domain in the workspace, chosen when you create or edit the link. This is what makes several domains per plan useful — a campaign domain, a support domain, and a product domain can all live in one workspace and appear in analytics together. **Caution: Short codes are immutable, domains are not** Moving a link to a different domain changes the URL you hand out. Anything already printed, posted, or emailed keeps pointing at the old hostname, and it will keep resolving only while that domain remains on the workspace. #### Universal Links and App Links Your `apple-app-site-association` and `assetlinks.json` files are served automatically on every custom domain you add. You do not upload or host them yourself. They are generated per host from your workspace's **App Configuration**, so that has to be filled in first: | Platform | Required in Settings → App Configuration | |---|---| | iOS | Team ID and Bundle ID | | Android | Package name and SHA-256 signing fingerprints | If those values are missing, the association file returns **404** on your domain and the OS silently declines to open your app — the link falls back to the browser with no error anywhere. If deep links open the web page instead of the app, check this first. You also need the domain listed in your app: - **iOS** — add `applinks:links.example.com` to the Associated Domains entitlement. - **Android** — add an intent filter for the host and make sure the signing fingerprint matches the one in App Configuration. #### Removing a Domain Removing a domain releases it from LinkForty and from Cloudflare. **Warning: Links on that domain stop resolving** Every link issued on a removed domain breaks immediately. The links still exist in your workspace with their click history intact, but the hostname no longer routes. Move them to another domain before removing one that is in use. You can also delete the CNAME record at your DNS provider afterwards; leaving it in place simply points at a host that no longer recognises your domain. #### Troubleshooting ##### "We can't see your DNS record yet" The CNAME has not propagated, or it is not pointing where LinkForty expects. - Confirm the record type is `CNAME`, not `A`. - Confirm the value matches the fallback origin shown in the dashboard exactly. - Check propagation with `dig links.example.com CNAME +short` — if it returns nothing, your provider has not published it yet. - If your DNS provider is Cloudflare, set the record to **DNS only** (grey cloud). An orange-cloud proxied record hides the CNAME behind Cloudflare's own proxy and verification cannot see it. ##### The certificate is still being issued Ownership is confirmed and only SSL remains. This normally completes within 15 minutes. Nothing to do but retry. ##### "That domain is already registered" The domain is attached to a workspace already — possibly one of your own. Remove it there first. If you believe it is registered to someone else in error, contact support; for privacy reasons the error does not say which workspace holds it. ##### "This domain cannot be verified automatically" The domain has reached a state that a retry will not clear. Remove it and add it again. If it returns to the same state, contact support. ##### The SSL certificate is no longer valid Certificates renew automatically, so this normally means the domain drifted out of a valid state. Remove the domain and add it again to request a fresh certificate. ##### Deep links open the browser instead of the app Almost always missing App Configuration — see [Universal Links and App Links](#universal-links-and-app-links) above. Confirm the association file is actually served: ```bash curl -s https://links.example.com/.well-known/apple-app-site-association curl -s https://links.example.com/.well-known/assetlinks.json ``` A `404` from either means the corresponding platform is not configured in your workspace. #### Self-Hosted Deployments This page describes LinkForty Cloud, where custom hostnames are provisioned for you. A self-hosted deployment points customers at its own service domain and needs its own Cloudflare for SaaS configuration — see [Self-Hosting](https://docs.linkforty.com/getting-started/self-hosting/) and [Environment Variables](https://docs.linkforty.com/reference/environment-variables/). #### Related - [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) — issuing links on a specific domain - [Link Templates](https://docs.linkforty.com/guides/links/link-templates/) — defaults applied to new links - [Deferred Deep Linking](https://docs.linkforty.com/guides/attribution/deferred-deep-linking/) — what happens when the app is not installed --- ## Link Templates Source: https://docs.linkforty.com/guides/links/link-templates/ **Info: Cloud Feature** Link templates are a Cloud-only feature. Self-hosted Core deployments do not include templates — links are created directly with all fields specified. See [Cloud vs Self-Hosted](https://docs.linkforty.com/getting-started/cloud-vs-self-hosted/) for details. Link templates allow you to create reusable configurations for your short links, saving time and ensuring consistency across campaigns. #### Overview Instead of filling in the same iOS URL, Android URL, attribution window, and UTM parameters for every link, create a template once and reuse it for all links in a campaign. **Benefits:** - **Save time** - Fill in common values once - **Ensure consistency** - All campaign links have same configuration - **Flexible overrides** - Override any template value per link when needed - **Team collaboration** - Share templates across organization - **Best practices** - Enforce attribution windows, UTM structure **Tip: How Template Defaults Work** Templates provide **default values** that are automatically applied when creating links. However, you can **override any template value** on a per-link basis by providing a different value when creating the link. This gives you the flexibility of reusable templates while allowing exceptions when needed. #### When to Use Templates ##### Campaign Templates Create one template per marketing campaign: **Instagram Campaign Template:** - iOS URL: Your App Store link - Android URL: Your Google Play link - Attribution Window: 7 days - UTM Medium: `social` - UTM Source: `instagram` All Instagram links inherit these defaults. You only specify what's unique (the destination URL, campaign name). ##### Platform Templates Create templates for each traffic source: - **Social Media Template** - 7-day attribution, social UTM - **Email Template** - 3-day attribution, email UTM - **Paid Search Template** - 3-day attribution, CPC UTM - **Display Ads Template** - 30-day attribution, display UTM - **Referral Template** - 90-day attribution, referral UTM ##### Use Case Templates Create templates for common scenarios: - **E-commerce Product Link** - App Store URLs, product tracking - **Content Deep Link** - Article/video routing - **Referral Link** - Long attribution window, referral tracking - **Event Promotion** - Event-specific settings #### Creating Templates ##### Via Dashboard **Step 1: Navigate to Templates** 1. Click **Settings** in sidebar 2. Click **Link Templates** 3. Click **"Create Template"** button **Step 2: Configure Template** **Basic Settings:** - **Template Name:** "Instagram Campaign" - **Description:** "Default template for all Instagram ads" **Platform URLs:** - **iOS URL:** `https://apps.apple.com/app/yourapp/id123456789` - **Android URL:** `https://play.google.com/store/apps/details?id=com.yourapp` - **Web Fallback URL:** `https://example.com/download` **Attribution:** - **Attribution Window:** 168 hours (7 days) **UTM Parameters:** ```json { "utm_medium": "social", "utm_source": "instagram" } ``` **Step 3: Save Template** Click **"Create Template"**. Template is now available when creating links. ##### Via API ```bash curl -X POST https://api.linkforty.com/api/templates \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Instagram Campaign", "description": "Default template for Instagram ads", "settings": { "defaultIosUrl": "https://apps.apple.com/app/yourapp/id123456789", "defaultAndroidUrl": "https://play.google.com/store/apps/details?id=com.yourapp", "defaultWebFallbackUrl": "https://example.com/download", "defaultAttributionWindowHours": 168, "utmParameters": { "medium": "social", "source": "instagram" } } }' ``` **Response:** ```json { "id": "template_a1b2c3d4", "name": "Instagram Campaign", "slug": "x7k9m2p5", "description": "Default template for Instagram ads", "settings": { "defaultIosUrl": "https://apps.apple.com/app/yourapp/id123456789", "defaultAndroidUrl": "https://play.google.com/store/apps/details?id=com.yourapp", "defaultWebFallbackUrl": "https://example.com/download", "defaultAttributionWindowHours": 168, "utmParameters": { "medium": "social", "source": "instagram" } }, "is_default": false, "created_at": "2024-01-20T10:00:00Z", "updated_at": "2024-01-20T10:00:00Z" } ``` #### Using Templates ##### Creating Links from Templates **Via Dashboard:** 1. Click **"Create Link"** 2. Select template from **"Template"** dropdown 3. Template values pre-fill the form 4. Fill in unique values: - Original URL: `https://example.com/product/123` - UTM Campaign: `spring-sale-2024` 5. Click **"Create Link"** **Via API:** ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "template_a1b2c3d4", "originalUrl": "https://example.com/product/123", "utmParameters": { "utm_campaign": "spring-sale-2024" } }' ``` **Result:** Link inherits iOS URL, Android URL, attribution window, utm_medium, and utm_source from template. You only provide the original URL and campaign name. ##### Overriding Template Values **Info: Override Behavior** You can override **any** template default value when creating a link. The backend applies this logic: ``` finalValue = userProvidedValue || templateDefaultValue || systemDefault ``` This means: 1. If you provide a value when creating the link → **your value is used** 2. If you don't provide a value → **template default is used** 3. If template doesn't have a default → **system default is used** (e.g., 168 hours for attribution window) **Example: Override attribution window and UTM medium** ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "template_a1b2c3d4", "originalUrl": "https://example.com/special-promo", "attributionWindowHours": 336, "utmParameters": { "campaign": "special-promo", "medium": "influencer" } }' ``` **Result:** - Uses template's iOS/Android URLs (not overridden) - Uses template's utm_source (not overridden) - Overrides attribution window (14 days instead of template's 7 days) - Overrides utm_medium (influencer instead of template's social) - Adds new utm_campaign (special-promo) #### Template Examples ##### Social Media Template ```json { "name": "Social Media Default", "description": "Template for all social media campaigns", "settings": { "defaultIosUrl": "https://apps.apple.com/app/id123", "defaultAndroidUrl": "https://play.google.com/store/apps/details?id=com.app", "defaultAttributionWindowHours": 168, "utmParameters": { "medium": "social" } } } ``` **Usage:** Set utm_source per platform (instagram, facebook, tiktok, twitter) when creating links. ##### Email Campaign Template ```json { "name": "Email Campaign", "description": "Template for email marketing", "settings": { "defaultIosUrl": "https://apps.apple.com/app/id123", "defaultAndroidUrl": "https://play.google.com/store/apps/details?id=com.app", "defaultAttributionWindowHours": 72, "utmParameters": { "medium": "email" } } } ``` **Usage:** Set utm_source to email service (mailchimp, sendgrid) and utm_campaign to campaign name when creating links. ##### Paid Search Template ```json { "name": "Google Ads", "description": "Template for Google Ads campaigns", "settings": { "defaultIosUrl": "https://apps.apple.com/app/id123", "defaultAndroidUrl": "https://play.google.com/store/apps/details?id=com.app", "defaultAttributionWindowHours": 72, "utmParameters": { "medium": "cpc", "source": "google" } } } ``` **Usage:** Add utm_campaign and utm_term per ad group when creating links. ##### Referral Program Template ```json { "name": "Referral Program", "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.app", "attributionWindowHours": 2160, "utmParameters": { "utm_medium": "referral", "utm_source": "friend" } } ``` **Usage:** Long 90-day attribution window for word-of-mouth referrals. ##### Content Marketing Template ```json { "name": "Blog Content", "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.app", "attributionWindowHours": 336, "utmParameters": { "utm_medium": "content", "utm_source": "blog" } } ``` **Usage:** Moderate 14-day window for educational content. #### Managing Templates ##### List All Templates **Via Dashboard:** Settings → Link Templates → View all templates **Via API:** ```bash curl -X GET https://api.linkforty.com/api/templates \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Response:** ```json { "templates": [ { "id": "template_a1b2c3d4", "name": "Instagram Campaign", "description": "Default template for Instagram ads", "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.app", "attributionWindowHours": 168, "utmParameters": { "utm_medium": "social", "utm_source": "instagram" }, "createdAt": "2024-01-20T10:00:00Z" }, { "id": "template_e5f6g7h8", "name": "Email Campaign", "attributionWindowHours": 72, "utmParameters": { "utm_medium": "email" }, "createdAt": "2024-01-15T09:00:00Z" } ], "total": 2 } ``` ##### Update Template **Via Dashboard:** 1. Settings → Link Templates 2. Click template to edit 3. Update fields 4. Click **"Save"** **Via API:** ```bash curl -X PUT https://api.linkforty.com/api/templates/template_a1b2c3d4 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Instagram Campaign (Updated)", "attributionWindowHours": 336 }' ``` **Important:** Updating a template does NOT update existing links. Only new links created from the template will use new values. ##### Delete Template **Via Dashboard:** 1. Settings → Link Templates 2. Click template 3. Click **"Delete"** button 4. Confirm deletion **Via API:** ```bash curl -X DELETE https://api.linkforty.com/api/templates/template_a1b2c3d4 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Important:** Deleting a template does NOT delete links created from it. Existing links remain unchanged. #### Advanced Strategies ##### Template Hierarchy Create a base template and specialized variants: **Base Template: "Mobile App Default"** ```json { "name": "Mobile App Default", "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.app", "attributionWindowHours": 168 } ``` **Variant 1: "Mobile App - Social"** ```json { "name": "Mobile App - Social", "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.app", "attributionWindowHours": 168, "utmParameters": { "utm_medium": "social" } } ``` **Variant 2: "Mobile App - Paid"** ```json { "name": "Mobile App - Paid", "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.app", "attributionWindowHours": 72, "utmParameters": { "utm_medium": "cpc" } } ``` ##### Campaign-Specific Templates Create template for each major campaign: ```javascript // Create campaign template programmatically async function createCampaignTemplate(campaignName, source, attributionDays) { return await fetch('https://api.linkforty.com/api/templates', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: `${campaignName} - ${source}`, iosUrl: 'https://apps.apple.com/app/id123', androidUrl: 'https://play.google.com/store/apps/details?id=com.app', attributionWindowHours: attributionDays * 24, utmParameters: { utm_campaign: campaignName.toLowerCase().replace(/\s+/g, '-'), utm_source: source } }) }); } // Create templates for Q1 2024 campaign await createCampaignTemplate('Spring Sale 2024', 'instagram', 7); await createCampaignTemplate('Spring Sale 2024', 'facebook', 7); await createCampaignTemplate('Spring Sale 2024', 'google', 3); ``` ##### Team Templates Share templates across your organization: 1. **Admin creates templates** with best practices 2. **Team members select from templates** when creating links 3. **Ensures consistency** across all campaigns 4. **Enforces attribution windows** (e.g., always 7 days for social) ##### Dynamic Template Selection Select template based on traffic source: ```javascript const TEMPLATES = { instagram: 'template_instagram', facebook: 'template_facebook', google: 'template_google', email: 'template_email', referral: 'template_referral' }; function createLink(url, source, campaign) { return fetch('https://api.linkforty.com/api/links', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ templateId: TEMPLATES[source], originalUrl: url, utmParameters: { utm_campaign: campaign } }) }); } // Automatically uses correct template await createLink('https://example.com/product', 'instagram', 'spring-2024'); ``` #### Best Practices ##### 1. Create Templates Before Links Set up templates before launching campaigns. This ensures all links follow same structure. ##### 2. One Template Per Traffic Source Create separate templates for each major traffic source: - Social media (Instagram, Facebook, TikTok, Twitter) - Paid search (Google, Bing) - Email - Display ads - Referrals - Content/organic ##### 3. Include All Platform URLs Always set iOS, Android, and web URLs in templates. Don't make team members fill these in manually. ##### 4. Set Appropriate Attribution Windows Match window to traffic source behavior: - Immediate channels (SMS, push): 1-6 hours - Direct response (search, email): 1-3 days - Social/content: 7-14 days - Brand awareness: 14-30 days - Referrals: 30-90 days ##### 5. Use Consistent UTM Structure Establish UTM naming conventions in templates: **Good:** ```json { "utm_medium": "social", "utm_source": "instagram" } ``` **Bad:** ```json { "utm_medium": "Social Media", "utm_source": "Instagram Stories" } ``` Use lowercase, no spaces, semantic naming. ##### 6. Document Template Purpose Add clear descriptions: ```json { "name": "Instagram - Stories", "description": "Use for all Instagram Stories campaigns. 7-day attribution, social UTM tags." } ``` ##### 7. Review Templates Quarterly Every 3 months: - Review attribution windows (are they optimal?) - Check if iOS/Android URLs changed - Update based on performance data - Remove unused templates ##### 8. Limit Template Count Don't create too many templates. Aim for 5-10 core templates covering main use cases. Too many templates = confusion. #### Template vs. Link Settings ##### What to Put in Templates **Template (consistent across campaign):** - iOS/Android URLs - Attribution window - UTM medium - UTM source (for platform-specific templates) - Default targeting rules **Link (unique per link):** - Original URL (destination) - UTM campaign - UTM content (ad variant) - Custom short code - Expiration date ##### Example **Template:** Facebook Ads Default ```json { "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.app", "attributionWindowHours": 168, "utmParameters": { "utm_medium": "cpc", "utm_source": "facebook" } } ``` **Link 1:** Spring sale product page ```json { "templateId": "template_facebook", "originalUrl": "https://example.com/spring-sale", "utmParameters": { "utm_campaign": "spring-2024", "utm_content": "carousel-ad-1" } } ``` **Link 2:** Summer sale product page ```json { "templateId": "template_facebook", "originalUrl": "https://example.com/summer-sale", "utmParameters": { "utm_campaign": "summer-2024", "utm_content": "video-ad-1" } } ``` Both links share template defaults (platform URLs, attribution, medium/source), but have unique URLs and campaigns. #### Comparison with Other Platforms | Feature | LinkForty | AppsFlyer | Branch | Adjust | |---------|-----------|-----------|---------|--------| | **Templates** | Unlimited | Limited (Enterprise) | No templates | No templates | | **API Access** | Yes | Yes (Enterprise) | Limited | Limited | | **Tier Availability** | **All tiers** | **Enterprise** | **N/A** | **N/A** | | **Team Sharing** | Yes | Yes | No | No | | **Override Values** | Yes | Yes | N/A | N/A | **LinkForty Advantage:** Templates included free, unlimited templates, full API access. #### Troubleshooting ##### Template Not Appearing in Dropdown **Cause:** Template belongs to different organization. **Solution:** Verify you're logged into correct organization. ##### Links Not Inheriting Template Values **Cause:** Template was updated after link creation. **Solution:** Template changes only affect new links. Update existing links manually or recreate. ##### Can't Delete Template **Cause:** Templates can always be deleted (links remain unaffected). **Solution:** If delete button is disabled, check permissions (must be Owner or Admin). #### Next Steps - [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) - Use templates when creating links - [Attribution Windows](https://docs.linkforty.com/guides/attribution/attribution-windows/) - Configure optimal windows - [UTM Parameters](https://docs.linkforty.com/guides/links/utm-parameters/) - Set up campaign tracking #### API Reference Template endpoints (require `Authorization: Bearer `): - `GET /api/templates` — List templates - `POST /api/templates` — Create a template - `GET /api/templates/:id` — Get a template - `PUT /api/templates/:id` — Update a template - `DELETE /api/templates/:id` — Delete a template - `PUT /api/templates/:id/set-default` — Set the default template --- ## UTM Parameters Source: https://docs.linkforty.com/guides/links/utm-parameters/ UTM parameters allow you to track the effectiveness of your marketing campaigns by tagging links with campaign information. #### What are UTM Parameters? UTM (Urchin Tracking Module) parameters are query string parameters added to URLs to track traffic sources in analytics tools. **Example:** ``` https://example.com/product?utm_source=instagram&utm_medium=social&utm_campaign=spring-sale ``` When a user clicks this link, your analytics know: - Traffic came from Instagram - It was a social media campaign - Part of the "spring-sale" campaign #### The 5 UTM Parameters ##### 1. utm_source (Required) **What:** Identifies the traffic source (where the traffic comes from). **Examples:** - `instagram` - Instagram - `google` - Google Search/Ads - `newsletter` - Email newsletter - `facebook` - Facebook **Info: The API takes bare key names** When you send `utmParameters` to the API, the keys are `source`, `medium`, `campaign`, `term` and `content` — **without** the `utm_` prefix. LinkForty adds the prefix when it builds the destination URL, so `{"source": "instagram"}` arrives at your page as `?utm_source=instagram`. Both spellings appear on this page for that reason: bare names when you are sending data, `utm_`-prefixed names when you are looking at a URL. Sending `utm_source` inside `utmParameters` does not error — the value is discarded and the link is created with no campaign tags. **Usage:** ```json { "utmParameters": { "source": "instagram" } } ``` ##### 2. utm_medium (Required) **What:** Identifies the marketing medium (type of traffic). **Common Values:** - `social` - Social media - `email` - Email marketing - `cpc` - Cost-per-click (paid ads) - `organic` - Organic search - `referral` - Referral traffic - `display` - Display advertising - `affiliate` - Affiliate marketing **Usage:** ```json { "utmParameters": { "source": "instagram", "medium": "social" } } ``` ##### 3. utm_campaign (Required) **What:** Identifies the specific campaign. **Examples:** - `spring-sale-2024` - Spring sale campaign - `product-launch` - Product launch - `black-friday` - Black Friday promotion - `back-to-school` - Back to school campaign **Usage:** ```json { "utmParameters": { "source": "instagram", "medium": "social", "campaign": "spring-sale-2024" } } ``` ##### 4. utm_term (Optional) **What:** Identifies paid search keywords. **Use For:** - Google Ads keywords - Bing Ads keywords - Paid search terms **Examples:** - `wireless+headphones` - Keyword bid - `running+shoes` - Product keyword - `best+phone` - Search term **Usage:** ```json { "utmParameters": { "source": "google", "medium": "cpc", "campaign": "product-ads", "term": "wireless+headphones" } } ``` **Note:** Not useful for social media or email campaigns. ##### 5. utm_content (Optional) **What:** Differentiates similar content or links within the same campaign. **Use For:** - A/B testing ad variants - Multiple links in same email - Different creatives **Examples:** - `banner-ad` vs `text-ad` - `header-link` vs `footer-link` - `red-button` vs `blue-button` - `video-1` vs `video-2` **Usage:** ```json { "utmParameters": { "source": "facebook", "medium": "cpc", "campaign": "summer-2024", "content": "carousel-ad-1" } } ``` #### UTM Best Practices ##### 1. Use Consistent Naming **Good - Consistent:** ``` utm_source=instagram utm_source=facebook utm_source=twitter ``` **Bad - Inconsistent:** ``` utm_source=Instagram utm_source=FB utm_source=twitter.com ``` **Rules:** - Always lowercase - Use hyphens instead of spaces - Be specific but concise - Don't use special characters ##### 2. Create a Naming Convention Document your UTM structure: ```javascript // UTM Naming Convention const UTM_CONVENTION = { source: { format: 'platform-name', examples: ['instagram', 'facebook', 'google', 'newsletter'] }, medium: { format: 'channel-type', examples: ['social', 'email', 'cpc', 'organic', 'referral'] }, campaign: { format: 'campaign-name-year', examples: ['spring-sale-2024', 'product-launch-q1', 'black-friday-2024'] }, term: { format: 'keyword-phrase', examples: ['wireless+headphones', 'running+shoes'] }, content: { format: 'variant-identifier', examples: ['carousel-ad-1', 'video-ad-2', 'banner-top'] } }; ``` ##### 3. Source Examples by Platform | Platform | utm_source | utm_medium | |----------|------------|------------| | **Instagram** | instagram | social | | **Facebook** | facebook | social or cpc | | **Twitter** | twitter | social | | **TikTok** | tiktok | social | | **LinkedIn** | linkedin | social or cpc | | **YouTube** | youtube | video or cpc | | **Google Ads** | google | cpc | | **Google Organic** | google | organic | | **Email Newsletter** | newsletter | email | | **Mailchimp** | mailchimp | email | | **SMS Campaign** | sms | sms | | **QR Code** | qr-code | offline | | **Referral** | referral | referral | ##### 4. Campaign Examples **E-commerce:** ```json { "utm_source": "instagram", "utm_medium": "social", "utm_campaign": "spring-sale-2024", "utm_content": "story-swipe-up" } ``` **SaaS Product Launch:** ```json { "utm_source": "producthunt", "utm_medium": "referral", "utm_campaign": "product-launch-v2", "utm_content": "launch-day" } ``` **Content Marketing:** ```json { "utm_source": "blog", "utm_medium": "content", "utm_campaign": "seo-growth-2024", "utm_content": "ultimate-guide-cta" } ``` **Paid Search:** ```json { "utm_source": "google", "utm_medium": "cpc", "utm_campaign": "brand-keywords", "utm_term": "linkforty+alternative", "utm_content": "text-ad-1" } ``` **Email Campaign:** ```json { "utm_source": "newsletter", "utm_medium": "email", "utm_campaign": "weekly-digest-23", "utm_content": "hero-cta" } ``` #### Adding UTM Parameters to Links ##### Via Dashboard **When creating a link:** 1. Fill in basic link details 2. Scroll to **"UTM Parameters"** section 3. Fill in fields: - Source: `instagram` - Medium: `social` - Campaign: `spring-sale-2024` - Content: `story-1` 4. Click **"Create Link"** **Result:** Parameters automatically appended to all URLs (original, iOS, Android, web). ##### Via API ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "template_123", "originalUrl": "https://example.com/product/123", "utmParameters": { "source": "instagram", "medium": "social", "campaign": "spring-sale-2024", "content": "carousel-ad-1" } }' ``` **How It Works:** LinkForty appends UTM parameters to your destination URLs: **Original URL:** ``` https://example.com/product/123 ``` **With UTM Parameters:** ``` https://example.com/product/123?utm_source=instagram&utm_medium=social&utm_campaign=spring-sale-2024&utm_content=carousel-ad-1 ``` ##### Via Link Templates Set default UTM parameters in templates: ```bash curl -X POST https://api.linkforty.com/api/templates \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Instagram Campaign", "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.app", "utmParameters": { "medium": "social", "source": "instagram" } }' ``` **Then create links from template:** ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "template_instagram", "originalUrl": "https://example.com/product/123", "utmParameters": { "campaign": "spring-sale-2024", "content": "story-1" } }' ``` **Result:** Inherits `utm_medium=social` and `utm_source=instagram` from template, adds campaign and content. #### Tracking UTM Parameters ##### In Link Analytics For performance across a whole campaign — short-link clicks and site sessions together — see the [Campaigns tab](https://docs.linkforty.com/guides/analytics/campaigns/). For one link at a time: 1. Go to **Analytics** → **Links** 2. Select your link 3. View **"Campaign Performance"** section 4. See breakdown by: - Source - Medium - Campaign - Content **Example:** ``` Campaign: spring-sale-2024 ├─ Source: instagram (450 clicks, 78 installs) ├─ Source: facebook (320 clicks, 52 installs) └─ Source: twitter (180 clicks, 24 installs) ``` ##### In Deep Link Data **Info: UTM names are reserved** `utm_source`, `utm_medium`, `utm_campaign`, `utm_term` and `utm_content` are consumed by LinkForty for campaign reporting and never reach your app under those names. To send a value **to the app**, use any other name — `?slug=titanic` arrives in `customParameters`. See [URL Parameter Passthrough](https://docs.linkforty.com/guides/attribution/deferred-deep-linking/#url-parameter-passthrough). UTM parameters reach the SDK under `utmParameters`, separately from your own values: ```typescript const deepLinkData = await LinkForty.getInstallData(); console.log(deepLinkData); // { // utmParameters: { // source: "instagram", // medium: "social", // campaign: "spring-sale-2024", // content: "story-1" // }, // customParameters: { // productId: "123" // } // } // Track where user came from if (deepLinkData.utm_source === 'instagram') { showInstagramSpecialOffer(); } ``` ##### Via API ```bash curl https://api.linkforty.com/api/analytics/links/abc123 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Response:** ```json { "linkId": "abc123", "totalClicks": 950, "utmBreakdown": { "source": { "instagram": 450, "facebook": 320, "twitter": 180 }, "medium": { "social": 950 }, "campaign": { "spring-sale-2024": 950 }, "content": { "story-1": 450, "post-1": 320, "tweet-1": 180 } } } ``` #### Advanced UTM Strategies ##### 1. Multi-Channel Campaigns Track same campaign across multiple channels: **Instagram:** ```json { "utm_source": "instagram", "utm_medium": "social", "utm_campaign": "product-launch-2024", "utm_content": "story-1" } ``` **Facebook:** ```json { "utm_source": "facebook", "utm_medium": "social", "utm_campaign": "product-launch-2024", "utm_content": "feed-ad-1" } ``` **Email:** ```json { "utm_source": "newsletter", "utm_medium": "email", "utm_campaign": "product-launch-2024", "utm_content": "header-cta" } ``` **Result:** See which channel drives most conversions for same campaign. ##### 2. A/B Testing with utm_content Test different creatives: **Variant A:** ```json { "utm_source": "facebook", "utm_medium": "cpc", "utm_campaign": "summer-2024", "utm_content": "video-ad-a" } ``` **Variant B:** ```json { "utm_source": "facebook", "utm_medium": "cpc", "utm_campaign": "summer-2024", "utm_content": "carousel-ad-b" } ``` **Analysis:** ``` Video Ad A: 500 clicks, 85 installs (17% CVR) Carousel Ad B: 500 clicks, 120 installs (24% CVR) → Winner: Carousel Ad B ``` ##### 3. Funnel Tracking Track user journey with different utm_content: **Top of Funnel (Awareness):** ```json { "utm_campaign": "brand-awareness-2024", "utm_content": "blog-post-cta" } ``` **Middle of Funnel (Consideration):** ```json { "utm_campaign": "brand-awareness-2024", "utm_content": "case-study-download" } ``` **Bottom of Funnel (Conversion):** ```json { "utm_campaign": "brand-awareness-2024", "utm_content": "free-trial-cta" } ``` ##### 4. Influencer Tracking Track individual influencers: ```json { "utm_source": "instagram", "utm_medium": "influencer", "utm_campaign": "influencer-q1-2024", "utm_content": "influencer-johndoe" } ``` **Analytics:** ``` johndoe: 1,200 clicks, 180 installs (15% conversion) janedoe: 800 clicks, 160 installs (20% conversion) → janedoe has better conversion rate ``` ##### 5. Geographic Campaigns Track by region: ```json { "utm_source": "facebook", "utm_medium": "cpc", "utm_campaign": "us-launch-2024", "utm_content": "us-west-coast" } ``` ```json { "utm_source": "facebook", "utm_medium": "cpc", "utm_campaign": "us-launch-2024", "utm_content": "us-east-coast" } ``` #### Common Mistakes to Avoid ##### 1. Inconsistent Capitalization **Bad:** ``` utm_source=Instagram utm_source=instagram utm_source=INSTAGRAM ``` **Good:** ``` utm_source=instagram (always) ``` ##### 2. Using Spaces **Bad:** ``` utm_campaign=spring sale 2024 ``` **Good:** ``` utm_campaign=spring-sale-2024 ``` ##### 3. Too Many Variations **Bad:** ``` utm_source=ig utm_source=insta utm_source=instagram utm_source=instagram-stories ``` **Good:** ``` utm_source=instagram (always) utm_content=story (to differentiate) ``` ##### 4. Missing Required Parameters **Bad:** ```json { "utm_campaign": "spring-2024" } ``` **Good:** ```json { "utm_source": "instagram", "utm_medium": "social", "utm_campaign": "spring-2024" } ``` ##### 5. Using Personal Information **Bad:** ``` utm_content=email-john.doe@example.com ``` **Good:** ``` utm_content=email-variant-a ``` **Why:** UTM parameters appear in URLs (visible to users) and analytics (possibly shared). #### UTM Parameter Tools ##### URL Builder Use LinkForty's built-in URL builder: ```javascript function buildUTMUrl(baseUrl, utmParams) { const url = new URL(baseUrl); Object.entries(utmParams).forEach(([key, value]) => { if (value) { url.searchParams.set(key, value); } }); return url.toString(); } const url = buildUTMUrl('https://example.com/product', { utm_source: 'instagram', utm_medium: 'social', utm_campaign: 'spring-2024' }); // https://example.com/product?utm_source=instagram&utm_medium=social&utm_campaign=spring-2024 ``` ##### Campaign URL Spreadsheet Create a spreadsheet to track all campaigns: | Campaign | Source | Medium | Content | Link | |----------|--------|--------|---------|------| | spring-2024 | instagram | social | story-1 | lnk.forty.com/abc123 | | spring-2024 | facebook | social | feed-1 | lnk.forty.com/def456 | | spring-2024 | newsletter | email | header | lnk.forty.com/ghi789 | #### Integration with Analytics Tools ##### Google Analytics UTM parameters appear automatically in: - Acquisition → All Traffic → Source/Medium - Acquisition → Campaigns → All Campaigns ##### Custom Analytics Parse UTM parameters in your app: ```javascript const url = new URL(window.location.href); const utmParams = { source: url.searchParams.get('utm_source'), medium: url.searchParams.get('utm_medium'), campaign: url.searchParams.get('utm_campaign'), term: url.searchParams.get('utm_term'), content: url.searchParams.get('utm_content') }; // Send to your analytics analytics.track('Page View', utmParams); ``` #### Next Steps - [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) - Add UTM parameters to links - [Analytics](https://docs.linkforty.com/guides/analytics/dashboard/) - View UTM performance - [Link Templates](https://docs.linkforty.com/guides/links/link-templates/) - Set default UTM parameters - [API Reference](https://docs.linkforty.com/api/endpoints/links/create-link/) - UTM parameter API #### Further Reading - [Google's UTM Guide](https://support.google.com/analytics/answer/1033863) --- ## UTM Library Source: https://docs.linkforty.com/guides/links/utm-library/ The UTM Library gives the raw UTM values in your traffic friendly names, descriptions, and tags — so your analytics read as **"Summer Newsletter"** instead of `sum_nl_2026_v2` everywhere they appear. **Info: Cloud feature** The UTM Library is part of LinkForty Cloud. It works alongside the [UTM parameters](https://docs.linkforty.com/guides/links/utm-parameters/) you already add to your links — it changes how those values *read*, not how they're tracked. #### Why use it UTM values are written for machines, not people. `summer_sale_2026`, `nl-jun`, `fb_ret_v3` made sense to whoever tagged the link, and to nobody else three weeks later. By the time those values reach your analytics, every breakdown is a wall of cryptic slugs. The UTM Library fixes the *reading* end of the problem without touching your links. You keep tagging links however you like; you teach LinkForty what each value means once, and that label shows up everywhere the value appears. #### What you can label The library covers all five UTM parameters: | Parameter | Source | |-----------|--------| | `utm_source` | Link clicks + web-pixel events | | `utm_medium` | Link clicks + web-pixel events | | `utm_campaign` | Link clicks + web-pixel events | | `utm_term` | Web-pixel events | | `utm_content` | Web-pixel events | For any observed value you can set: - **Display name** — `sum_nl_2026_v2` becomes **Summer Newsletter**. - **Description** — context for the rest of the team: what the campaign was, when it ran, who owned it. - **Tags** — group related values so you can tell paid from organic, or one quarter's pushes from another, at a glance. #### Values are discovered automatically You never create values by hand. The set is discovered from your actual traffic — the library pools every `utm_*` value seen across your link clicks and your web-pixel events, and shows each one with: - **Usage count** — how many times the value has been seen. - **Last seen** — when it most recently appeared. New values you start using appear on their own. There's nothing to register. #### Labels read everywhere The point isn't a single tidy page — it's that the labels travel. Once you've named a value, that friendly name renders across the dashboard surfaces that show UTM values: campaign breakdowns, source filters, and analytics tables all read in plain language instead of raw slugs. The underlying value is untouched, so attribution, filtering, and exports keep working exactly as before — you've added a display layer, not rewritten your data. Remove a label and the value simply goes back to showing its raw form; it never disappears, because it's still out there in your traffic. #### Getting started 1. Open the **UTM Library** page in your [dashboard](https://app.linkforty.com). 2. Browse the values discovered from your clicks and web events, sorted by usage. 3. Add a display name, description, and tags to the ones your team sees most. 4. Watch your campaign and source breakdowns across the dashboard switch from slugs to real names. Labels are managed by any team member with link-editing access. #### Related - [UTM Parameters](https://docs.linkforty.com/guides/links/utm-parameters/) — how to tag your links in the first place. - [Analytics Dashboard](https://docs.linkforty.com/guides/analytics/dashboard/) — where labeled values show up. - [Event Library](https://docs.linkforty.com/guides/analytics/event-library/) — the same idea, applied to your event names. --- ## QR Codes Source: https://docs.linkforty.com/guides/links/qr-codes/ Generate dynamic QR codes for your short links to enable offline-to-online attribution tracking. #### Overview LinkForty's QR code generation allows you to create scannable codes for print materials, product packaging, event signage, and more. Unlike static QR codes, LinkForty QR codes are dynamic - you can update the destination URL without reprinting the code. **Key Features:** - Dynamic QR codes (update destination anytime) - Multiple formats (PNG, SVG) - Custom sizes (128px - 2048px) - Brand colors support - Full attribution tracking - Click analytics by scan location #### Why QR Codes? ##### Offline-to-Online Attribution Track how offline marketing drives app installs: **Traditional Approach:** - Print ad with URL → Hard to type → Low conversion - No way to track which magazine/billboard drove installs **LinkForty QR Codes:** - User scans code → Instant redirect → Higher conversion - Full attribution: which print ad, when scanned, where installed - Track ROI on offline marketing spend ##### Use Cases **Print Advertising:** - Magazine ads - Newspaper inserts - Direct mail - Flyers and brochures **Product Packaging:** - Product boxes - Instruction manuals - Warranty cards - Recipe cards **Retail & Events:** - In-store displays - Event posters - Conference badges - Table tents at restaurants **Business Materials:** - Business cards - Letterhead - Invoices - Presentations #### Generating QR Codes ##### Via Dashboard **Step 1: Navigate to Link** 1. Go to **Links** page 2. Click on the link you want to create a QR code for 3. Click **"Generate QR Code"** button **Step 2: Configure Options** **Size:** - 128x128px - Small (business cards) - 256x256px - Medium (flyers) - 512x512px - Large (posters) - 1024x1024px - Print quality - 2048x2048px - Billboard/large format **Format:** - PNG - Raster image (photos, web) - SVG - Vector image (print, scalable) **Color:** - Black (default) - Custom hex color (#FF0000) - Match your brand colors **Step 3: Download** Click **"Download"** and save the file. ##### Via API ###### Generate PNG QR Code ```bash curl -X GET "https://api.linkforty.com/api/links/abc123/qr?size=512&format=png" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ --output qr-code.png ``` ###### Generate SVG QR Code ```bash curl -X GET "https://api.linkforty.com/api/links/abc123/qr?size=1024&format=svg" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ --output qr-code.svg ``` ###### With Custom Color ```bash curl -X GET "https://api.linkforty.com/api/links/abc123/qr?size=512&format=png&color=0066CC" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ --output qr-code-blue.png ``` ##### Programmatic Generation ###### JavaScript/TypeScript ```typescript // Generate QR code via API const response = await fetch( 'https://api.linkforty.com/api/links/abc123/qr?size=512&format=png', { headers: { 'Authorization': `Bearer ${API_KEY}` }, } ); const qrCode = await response.arrayBuffer(); // Save to file (Node.js) const fs = require('fs'); fs.writeFileSync('qr-code.png', Buffer.from(qrCode)); ``` #### QR Code Best Practices ##### Size Guidelines | Use Case | Recommended Size | Scan Distance | |----------|------------------|---------------| | Business cards | 128-256px | 4-6 inches | | Flyers/brochures | 256-512px | 6-12 inches | | Posters | 512-1024px | 1-3 feet | | Billboards | 2048px+ | 10+ feet | | Product packaging | 256-512px | 6-12 inches | | Table tents | 512px | 1-2 feet | **Formula:** Minimum size = Scan distance / 10 Example: Billboard viewed from 30 feet away needs 3-inch QR code minimum. ##### Print Quality **For Professional Printing:** - Use **SVG format** (vector, scales infinitely) - Or use **PNG at 300 DPI** minimum - Size: 1024px or larger - Include "quiet zone" (white space) around QR code **Quiet Zone:** - Minimum 4 modules (squares) of white space on all sides - No text, images, or colors in quiet zone - Essential for reliable scanning ##### Color Considerations **Safe Color Combinations:** - Dark QR code on light background (best contrast) - Black on white (highest reliability) - Dark blue/red/green on white **Avoid:** - Light colors on dark backgrounds - Low contrast combinations - Inverted colors (white QR on black) - Gradient fills **Contrast Ratio:** - Minimum 3:1 ratio required - 7:1 ratio recommended for best results ##### Testing Always test QR codes before printing: 1. **Print test copy** at actual size 2. **Test with multiple devices:** - iPhone (built-in camera) - Android (Google Lens) - Older devices 3. **Test in actual lighting conditions** 4. **Test from expected scan distance** #### Dynamic QR Codes ##### What Makes Them Dynamic? **Static QR Code:** - URL encoded directly in QR pattern - Cannot be changed after creation - Reprint required for new destination **LinkForty Dynamic QR Code:** - QR contains short link (lnk.forty.com/abc123) - Short link redirects to your destination - Update destination anytime without reprinting ##### Updating Destination Update where QR code sends users without reprinting: **Via Dashboard:** 1. Go to **Links** → Find your link 2. Click **"Edit"** 3. Change **Original URL** or platform URLs 4. Click **"Save"** 5. QR code now redirects to new destination **Via API:** ```bash curl -X PUT https://api.linkforty.com/api/links/abc123 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "originalUrl": "https://example.com/new-product" }' ``` **Use Cases:** - Seasonal campaigns (update for new season) - Product updates (new product pages) - Event changes (venue or schedule updates) - A/B testing (test different landing pages) #### Attribution Tracking ##### How It Works 1. **User scans QR code** from magazine ad 2. **Phone opens camera app** → Recognizes QR code 3. **Redirects to short link** (lnk.forty.com/abc123) 4. **LinkForty logs click event:** - Timestamp - Location (city/country) - Device type (iPhone 14, Pixel 7) - Referrer: QR scan 5. **User redirected** based on device (App Store, Google Play, web) 6. **User installs app** 7. **SDK checks for attribution** within attribution window 8. **Install attributed to QR scan** ##### Analytics View QR code scan analytics: **Via Dashboard:** 1. **Analytics** → Select link 2. View metrics: - Total scans - Scans by location - Scans by device - Time of day distribution - Conversion rate (scans → installs) **Example Insights:** ``` Magazine QR Code (June 2024) - Total scans: 2,456 - Top cities: New York (412), Los Angeles (298), Chicago (187) - Peak time: 7-9 PM weekdays - iOS: 62%, Android: 38% - Installs: 421 (17.1% conversion rate) ``` ##### UTM Parameters Track which print materials perform best: ```bash ### Magazine ad curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "template_123", "originalUrl": "https://example.com/promo", "customCode": "magazine-june", "utmParameters": { "utm_source": "vogue_magazine", "utm_medium": "print", "utm_campaign": "summer_2024", "utm_content": "full_page_ad" } }' ``` **Analytics breakdown:** - Vogue magazine: 1,200 scans - Elle magazine: 800 scans - Local paper: 456 scans **Performance metrics:** ``` Scans: 1,200 Installs: 204 Conversion rate: 17% ``` #### Advanced Techniques ##### Branded QR Codes Add logo in center (reduces scan reliability slightly): 1. Generate QR code at 1024px+ 2. Open in image editor 3. Place logo in center (max 20% of QR size) 4. Ensure logo has white border 5. Test thoroughly **Caution:** QR codes have error correction (30% max), but logos reduce reliability. ##### Custom URL Schemes Deep link directly into specific app sections: ```json { "originalUrl": "https://example.com/product/123", "iosUrl": "yourapp://product/123", "androidUrl": "yourapp://product/123" } ``` **User flow:** 1. Scan QR code 2. If app installed → Opens directly to product 3. If app not installed → App Store/Google Play 4. After install → Deferred deep link to product ##### Location-Specific QR Codes Create unique QR codes per location: ```bash ### Store #1 - New York { "customCode": "store-nyc", "utmContent": "store_nyc" } ### Store #2 - Los Angeles { "customCode": "store-la", "utmContent": "store_la" } ``` **Analytics show:** - Which stores drive most scans - Which locations have highest conversion - Geographic performance comparison ##### Expiring QR Codes For time-limited campaigns: ```json { "customCode": "summer-sale-2024", "expiresAt": "2024-08-31T23:59:59Z" } ``` After expiration, QR code shows "This promotion has ended." #### Troubleshooting ##### QR Code Won't Scan **Possible causes:** 1. **Too small** - Increase size (512px minimum) - Increase print size 2. **Low contrast** - Use black on white - Avoid colored backgrounds 3. **Damaged or distorted** - Ensure flat surface - Check for wrinkles/tears - Verify no glare from lighting 4. **No quiet zone** - Add white space around QR code - Minimum 4 modules on all sides 5. **Format issues** - Use PNG or SVG (not JPEG) - Check resolution (300 DPI for print) ##### Poor Scan Rate **If scans are lower than expected:** 1. **Test visibility** - Is QR code large enough? - Good contrast with background? - Proper lighting? 2. **Add call-to-action** - "Scan for 20% off" - "Scan to download app" - Include phone icon + text 3. **Placement** - Eye level preferred - Easy to reach - Good lighting - No obstructions 4. **Educate users** - Some users don't know how to scan - Add brief instructions - "Point camera at code to scan" ##### Scans But No Installs **If scans are high but installs are low:** 1. **Check attribution window** - May be too short - Recommend 7-30 days for print 2. **Improve landing page** - Clear value proposition - Easy download process - Fast loading 3. **Platform-specific URLs** - Ensure iOS/Android URLs set - Test on both platforms #### Platform Comparison ##### LinkForty QR Codes **All tiers (Free, Pro, Self-hosted):** - Unlimited QR code generation - All sizes and formats (PNG, SVG) - Custom colors and branding - Dynamic updates (change destination without reprinting) - Full analytics and attribution ##### Other Platforms **Generic QR Code Generators:** - Free tools: Static only (can't update after printing) - Dynamic services: Per-code fees or subscriptions **Enterprise Attribution Platforms:** - Often a separate product or add-on - Typically limited to enterprise tiers - May have limited customization options **LinkForty Advantage:** Included in all tiers, unlimited, fully featured #### Best Practices Summary 1. **Size for scan distance:** Larger QR codes for farther viewing 2. **Use SVG for print:** Scalable, professional quality 3. **High contrast:** Black on white works best 4. **Test before printing:** Multiple devices, actual conditions 5. **Include call-to-action:** Tell users why to scan 6. **Add quiet zone:** White space around all edges 7. **Use dynamic codes:** Update destination without reprinting 8. **Track with UTM:** Know which placements perform best 9. **Set appropriate attribution window:** 7-30 days for print 10. **Monitor analytics:** Optimize based on scan data #### Next Steps - [Create Links](https://docs.linkforty.com/guides/links/creating-links/) - Generate links for QR codes - [Analytics](https://docs.linkforty.com/guides/analytics/dashboard/) - Track QR code performance - [UTM Parameters](https://docs.linkforty.com/guides/links/utm-parameters/) - Set up campaign tracking - [API Reference](https://docs.linkforty.com/api/endpoints/links/create-link/) - Automate QR generation #### Examples ##### Restaurant Menu QR Code ```json { "customCode": "menu", "originalUrl": "https://restaurant.com/menu", "iosUrl": "https://apps.apple.com/app/restaurant/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.restaurant", "utmParameters": { "utm_source": "table_tent", "utm_medium": "qr_code", "utm_campaign": "digital_menu" } } ``` Generate 512px PNG, print on table tents. Track which tables drive most app downloads. ##### Product Packaging QR Code ```json { "customCode": "product-guide", "originalUrl": "https://example.com/setup-guide", "utmParameters": { "utm_source": "product_packaging", "utm_medium": "qr_code", "utm_campaign": "setup_assistance", "utm_content": "box_insert" } } ``` Generate 256px SVG, print on instruction card. Track how many users scan for setup help. ##### Event Badge QR Code ```json { "customCode": "conference-2024", "originalUrl": "https://event.com/schedule", "utmParameters": { "utm_source": "conference_badge", "utm_medium": "qr_code", "utm_campaign": "conference_2024" }, "expiresAt": "2024-06-30T23:59:59Z" } ``` Generate 256px PNG, print on badges. Link expires after event ends. --- ## Shared Links Source: https://docs.linkforty.com/guides/links/shared-links/ Track and analyze links created by your app users through the mobile SDK. #### Overview When your app has a "Share" button that creates short links via the SDK, those links appear on the **Shared Links** page in your dashboard. This gives you visibility into: - Which users share the most - What content gets shared - How many clicks shared links receive - Sharing trends over time Shared Links are separate from marketing links you create in the dashboard or via the API. They have their own dedicated page with sharing-specific analytics. **Info: Prerequisites** Before your app can create shared links: 1. **Integrate a mobile SDK** -- see [React Native](https://docs.linkforty.com/sdks/react-native/), [Expo](https://docs.linkforty.com/sdks/expo/), [iOS](https://docs.linkforty.com/sdks/ios/), or [Android](https://docs.linkforty.com/sdks/android/) 2. **Configure an API key** in your SDK initialization 3. **Create at least one link template** -- the SDK uses your default template, or you can pass a specific `templateId` --- #### How It Works 1. A user taps "Share" in your app 2. Your app calls `createLink()` with an `externalUserId` identifying the user and `deepLinkParameters` describing the content 3. The server creates a short link (or returns an existing one if this user already shared this content) 4. Your app shares the returned URL 5. The link and its analytics appear on the Shared Links dashboard --- #### Per-User Deduplication When both `externalUserId` and `deepLinkParameters` are provided, the SDK endpoint automatically deduplicates links: - **Same user + same content** = returns the existing link (no duplicate created) - **Different user + same content** = creates a new link (enables per-user attribution) - **Same user + different content** = creates a new link When an existing link is returned, the response includes `deduplicated: true`. This lets your app know no new link was created. **Tip: Why per-user deduplication matters** Without deduplication, a user tapping "Share" 10 times on the same product creates 10 identical links. With deduplication, they always get the same link -- keeping your dashboard clean and enabling accurate per-user share analytics. Deduplication is **only active** when both `externalUserId` AND `deepLinkParameters` are provided. If either is missing, a new link is always created. --- #### Creating Shared Links from Your App ##### Set the User ID Call `setExternalUserId()` once after the user logs in. The SDK automatically attaches it to all `createLink()` calls: ```typescript // React Native / Expo LinkForty.setExternalUserId(currentUser.id); // On logout LinkForty.setExternalUserId(null); ``` ```swift // iOS LinkForty.shared.setExternalUserId(currentUser.id) // On logout LinkForty.shared.setExternalUserId(nil) ``` ```kotlin // Android LinkForty.shared.setExternalUserId(currentUser.id) // On logout LinkForty.shared.setExternalUserId(null) ``` ##### Create a Shared Link Once the user ID is set, `createLink()` includes it automatically: ##### React Native / Expo ```typescript import LinkForty from '@linkforty/mobile-sdk-react-native'; // or: import { LinkForty } from '@linkforty/mobile-sdk-expo'; const result = await LinkForty.createLink({ deepLinkParameters: { route: 'PRODUCT', id: '456' }, title: 'Check out this product', }); if (result.deduplicated) { console.log('Returning existing link for this user + content'); } // Share result.url with the user await Share.share({ url: result.url }); ``` You can also pass `externalUserId` per-call to override the SDK-level value: ```typescript const result = await LinkForty.createLink({ externalUserId: specificUser.id, // overrides SDK-level value deepLinkParameters: { route: 'PRODUCT', id: '456' }, }); ``` ##### iOS (Swift) ```swift let result = try await LinkForty.shared.createLink(options: CreateLinkOptions( deepLinkParameters: ["route": "PRODUCT", "id": "456"], title: "Check out this product" )) if result.deduplicated == true { print("Returning existing link for this user + content") } // Share result.url ``` ##### Android (Kotlin) ```kotlin val result = LinkForty.shared.createLink( CreateLinkOptions( deepLinkParameters = mapOf("route" to "PRODUCT", "id" to "456"), title = "Check out this product" ) ) if (result.deduplicated == true) { println("Returning existing link for this user + content") } // Share result.url ``` --- #### Dashboard Navigate to **Shared Links** in the left sidebar to view your sharing analytics. ##### Summary Stats Four cards at the top of the page show: | Stat | Description | |------|-------------| | **Total shared links** | Total number of links created via the SDK | | **Unique sharers** | Number of distinct `externalUserId` values | | **Total clicks** | Combined click count across all shared links | | **Shares today** | Links created via the SDK today | ##### Top Sharers Below the stats, a row of clickable chips shows your top 10 sharers ranked by link count. Each chip displays the user's external ID, number of links created, and total clicks received. Click a chip to filter the table to that user. ##### Links Table The table shows all SDK-created links with the following columns: | Column | Description | |--------|-------------| | **User** | The `externalUserId` passed when creating the link | | **Content** | Deep link parameters (shown as key-value badges) | | **Short Link** | The generated short code with a copy button | | **Clicks** | Total click count for this link | | **Created** | When the link was created | ##### Filtering Use the **User** filter pill to search by external user ID. The search matches partial user IDs. --- #### Best Practices ##### Use Stable User IDs Pass a persistent, unique identifier as `externalUserId` -- such as a database user ID or Firebase UID. Avoid transient values like session IDs or anonymous tokens, as these break deduplication and make the top sharers list meaningless. ##### Always Include Deep Link Parameters Deep link parameters serve two purposes: they route users to the right content after install, and they're part of the deduplication key. Without them, deduplication won't activate. ```typescript // Good -- deduplication works, content is identifiable await LinkForty.createLink({ externalUserId: user.id, deepLinkParameters: { productId: '123', category: 'shoes' }, }); // Less useful -- no deduplication, content unknown in dashboard await LinkForty.createLink({ externalUserId: user.id, }); ``` ##### Use Descriptive Parameter Keys The deep link parameters appear in the Shared Links table as badges. Use human-readable keys so your team can identify what content was shared at a glance. --- #### Related Guides - [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) -- create links via the dashboard and API - [Link Templates](https://docs.linkforty.com/guides/links/link-templates/) -- required for SDK link creation - [SDK Integration](https://docs.linkforty.com/guides/attribution/sdk-integration/) -- set up mobile attribution - [React Native SDK](https://docs.linkforty.com/sdks/react-native/) | [Expo SDK](https://docs.linkforty.com/sdks/expo/) | [iOS SDK](https://docs.linkforty.com/sdks/ios/) | [Android SDK](https://docs.linkforty.com/sdks/android/) --- ## Social Share Preview Source: https://docs.linkforty.com/guides/links/social-previews/ Control the image, title, and description that appear when your short links are shared on Slack, WhatsApp, X, LinkedIn, Discord, Facebook, iMessage, and Bluesky. #### What a Share Card Is When someone posts a link, the receiving platform fetches it and builds a preview from the page's [Open Graph](https://ogp.me/) tags. Without one, your link unfurls as a bare URL — no image, no context, nothing that says who it came from. Worse than plain: with no image at all, the card downgrades to the small text-only format on most platforms, so the link takes up less space *and* carries less signal. There are two ways to get a card. **Generating one is the normal path.** Uploading is for when you already have artwork. #### Generating a Card Go to **Social Share Preview** in the dashboard sidebar, pick a template, fill in the fields, and save. ##### The Templates | Template | What it is | Use it when | |---|---|---| | **Title card** | Text on a solid background, with an optional logo | You have no artwork. The only template that needs no image at all | | **Hero split** | Copy on the left, image on the right — brand name, badge, title, description, and a call-to-action pill | You have a product shot and something to say about it | | **Image cover** | Full-bleed image with the title overlaid on a scrim | You have a strong photograph and want it to carry the card | Every template takes a **title** — that is the one required field. Everything else (description, logo, colours, imagery, eyebrow text) is optional and varies by template; the form shows you what the one you picked accepts. Both image-based templates degrade rather than break. If an image is missing or fails to fetch, Hero split gives the copy the full width and Image cover falls back to a solid background, so you get a plain card rather than a broken one. ##### Live Preview The panel beside the form renders the actual card as you type, using your workspace's own domain so the preview matches what recipients will see. Nothing is saved until you save. The preview is never cached. **Info: Cards are rendered when you save, not when a link is shared** The image is generated once, at save time, and stored. Scrapers are served a finished image. This matters more than it sounds: Facebook's crawler gives up after a few seconds, and Bluesky bakes whatever it receives into the post permanently. Rendering on demand would mean some posts capture a timeout forever. #### Your Workspace Already Has One New workspaces get a share card generated automatically — a Title card built from the workspace name, on a colour derived from the workspace itself so it is stable and not identical to anyone else's. So the usual first job is **editing the card you already have**, not creating one from nothing. Open the page and you will see it. #### Uploading Your Own Image If you have artwork already — a designed campaign asset, a brand banner — upload it instead. - **Recommended size:** 1200 × 630 pixels, the standard Open Graph ratio - **Format:** PNG or JPEG - **Maximum:** 5 MB A smaller thumbnail is produced automatically and is what most chat platforms receive, so unfurls stay fast. **Note: Uploaded or generated, never both** A preview has one image. Choosing a template replaces an uploaded image, and uploading replaces a generated card. The dashboard reopens the form in whichever mode you last saved, with your previous values intact. #### Who Can Change It | Action | Required role | |---|---| | View the page | Any signed-in member | | Change the card, upload, or remove an image | **Admin** or above | #### Org Default and Per-Template Cards Cards are configured at two levels: - **Organization default** — one card, used by every link in the workspace. - **Per template** — a card for links built from one specific template, for a campaign with its own branding. Switch between them with the tabs on the Social Share Preview page. One org-level card covers everything you do not explicitly override. #### How a Card Is Resolved When a platform scrapes one of your short links, each field is resolved independently, first match wins: 1. **The link's own `og_title` / `og_description` / `og_image_url`**, set through the API with `ogTitle`, `ogDescription`, and `ogImageUrl` on [`POST /api/links`](https://docs.linkforty.com/api/endpoints/links/create-link/), [`PUT /api/links/:id`](https://docs.linkforty.com/api/endpoints/links/update-link/), or `POST /api/sdk/v1/links` 2. **The template's card**, if the link came from a template that has one 3. **The organization default card** 4. **The link's title and description** 5. `Shared Link` as a last resort for the title **Caution: Link-level OG fields override your cards** A link with `og_image_url` set ignores both the template card and the org default. If a specific link is not showing the card you configured, check whether it has its own OG fields — they win. Send `null` for a field on `PUT /api/links/:id` to clear it. Link-level fields are the right tool for **user-shared content**: when your backend creates a link for a specific item, pass that item's title and image so the share unfurls as the item rather than as your workspace. The dashboard does not edit these fields; they are API-only. Because each field resolves on its own, a link can take its title from one layer and its image from another. #### Removing a Card **Remove image** clears the image and keeps the title and description. To clear the preview entirely, empty the title field and save. #### Checking a Card Before You Send It Paste the link into Slack — it unfurls inline immediately, which is the fastest check. For the others: - **X / Twitter** — [Card Validator](https://cards-dev.twitter.com/validator) - **LinkedIn** — [Post Inspector](https://www.linkedin.com/post-inspector/) - **Facebook** — [Sharing Debugger](https://developers.facebook.com/tools/debug/) If you see the old card, the platform cached it. Every validator above has a re-scrape option that forces a fresh fetch. **Tip: Check any URL without signing in** The free [Social Share Preview tool](https://linkforty.com/tools/og-preview) renders the card for any public URL, LinkForty link or not. Useful for auditing your own site's pages, or for checking a link before you own it. #### How It Works ##### Scraper Detection The redirect server identifies known crawlers by User-Agent — `facebookexternalhit`, `Twitterbot`, `LinkedInBot`, `Slackbot`, `Discordbot`, `TelegramBot`, `WhatsApp`, `PinterestBot`, `SkypeUriPreview`, plus iMessage, Reddit, Bluesky, and Mastodon validators — and serves them an HTML page carrying the Open Graph and Twitter Card tags. **Human visitors are unaffected.** They get the normal redirect and never see that page. ##### Image Delivery Images live in S3-compatible object storage, and the OG tags point straight at the public URL. There is no round-trip through the application server on a scrape. ##### Caching The link lookup and the preview configuration are both cached in Redis for five minutes, so scraper traffic does not reach the database on every request and the redirect path stays fast. #### Self-Hosting Share cards need S3-compatible object storage. Without it, generating a card returns a "storage is not configured" error and the dashboard says so plainly. Set: ```bash AWS_BUCKET=your-bucket-name AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... ``` See the [Self-Hosting Guide](https://docs.linkforty.com/getting-started/self-hosting/) and [Environment Variables](https://docs.linkforty.com/reference/environment-variables/). #### Related - [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) — setting per-link OG fields - [Link Templates](https://docs.linkforty.com/guides/links/link-templates/) — what a per-template card attaches to - [Custom Domains](https://docs.linkforty.com/guides/links/custom-domains/) — the domain your cards are served from --- # Mobile Attribution ## Deferred Deep Linking Source: https://docs.linkforty.com/guides/attribution/deferred-deep-linking/ Deferred deep linking allows you to route new users to specific content after they install your app, creating a seamless experience from click to conversion. #### What is Deferred Deep Linking? **Regular deep linking** only works for users who already have your app installed. **Deferred deep linking** works for new users who don't have your app yet: 1. User clicks link to product page 2. User doesn't have app → Sent to App Store/Google Play 3. User installs app 4. User opens app for first time 5. **App automatically navigates to the original product page** The "deep link" is **deferred** until after install. #### Why It Matters ##### Without Deferred Deep Linking **User clicks:** Instagram ad for wireless headphones **Flow:** 1. Click ad → App Store 2. Install app 3. Open app → **Generic home screen** 4. User has to search for headphones again 5. **60-70% drop-off** - user forgets or gives up ##### With Deferred Deep Linking **User clicks:** Same Instagram ad **Flow:** 1. Click ad → App Store 2. Install app 3. Open app → **Automatically shows wireless headphones** 4. User adds to cart immediately 5. **15-25% drop-off** - seamless experience **Impact:** **3-5x higher conversion rate** from click to purchase. #### How It Works ##### 1. Link Creation Create a link with deep link parameters: ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "template_123", "originalUrl": "https://example.com/products/123?productId=123&category=electronics", "iosUrl": "https://apps.apple.com/app/id123456789", "androidUrl": "https://play.google.com/store/apps/details?id=com.yourapp", "attributionWindowHours": 168 }' ``` **Generated:** `https://lnk.forty.com/abc123` One link can serve many destinations: append query parameters when you share it and they reach the app after install, so you do not need a separate link per item. See [URL Parameter Passthrough](#url-parameter-passthrough). ##### 2. User Clicks Link When user clicks, LinkForty: 1. **Detects device** (iOS, Android, or Web) 2. **Creates fingerprint** from: - IP address - User-agent - Screen resolution - Timezone - Language 3. **Stores fingerprint** with link data and timestamp 4. **Redirects** to App Store (iOS) or Google Play (Android) ##### 3. User Installs App User downloads and installs from store. **Important:** Attribution window starts when user clicks link, not when they install. ##### 4. User Opens App On first launch, the SDK reports the install and invokes your deferred deep link handler: ```typescript import LinkForty from '@linkforty/mobile-sdk-react-native'; LinkForty.onDeferredDeepLink((deepLinkData) => { // deepLinkData is the attributed link's data, or null for organic installs }); ``` ##### 5. LinkForty Matches Install SDK: 1. **Generates fingerprint** from same device signals 2. **Sends to LinkForty** API 3. **Searches for matching click** within attribution window 4. **Returns deep link data** if match found ```javascript // Returns: { productId: "123", category: "electronics", utm_source: "instagram", utm_campaign: "spring-sale" } ``` ##### 6. App Navigates Your app uses deep link data to navigate: ```typescript if (deepLinkData && deepLinkData.productId) { navigation.navigate('Product', { id: deepLinkData.productId }); } ``` User sees the original product page they clicked on! #### Fingerprint Matching ##### What is Fingerprinting? Fingerprinting creates a unique identifier from device characteristics **without** requiring: - Device IDs (IDFA, GAID) - User accounts - Cookies - App Tracking Transparency permission (iOS) ##### Fingerprint Components LinkForty uses these signals: | Signal | Example | Stability | |--------|---------|-----------| | **IP Address** | 192.168.1.1 | High | | **User-Agent** | iPhone14,2; iOS 17.1 | Very High | | **Screen Resolution** | 1170x2532 | Very High | | **Timezone** | America/New_York | High | | **Language** | en-US | High | | **Platform** | iOS | Very High | Combined, these create a fingerprint like: ``` fp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 ``` ##### Match Accuracy **Expected accuracy: 70-80%** | Scenario | Match Rate | |----------|------------| | Same WiFi, same device, under 24h | 90-95% | | Same cellular, same device, under 7 days | 75-85% | | Different network (WiFi → cellular) | 60-70% | | VPN or proxy in between | 30-50% | | Over 14 days between click and install | 50-60% | ##### Why Matches Fail **Common reasons for no match:** 1. **Network changed** - Clicked on WiFi, installed on cellular 2. **VPN/proxy used** - IP address changes 3. **Attribution window expired** - Installed too long after click 4. **Different browser** - In-app browser vs Safari (iOS) 5. **iOS Private Relay** - Hides IP address 6. **Multiple devices** - Clicked on iPad, installed on iPhone ##### Improving Match Rate **1. Longer attribution windows** - 7 days: 70-75% match rate - 14 days: 75-80% match rate - 30 days: 65-70% match rate (more false positives) **2. Platform-specific links** - Create separate links for iOS and Android - Reduces ambiguity in matching **3. Direct traffic** - Social media → Good match rates (in-app browsers) - SMS/iMessage → Excellent match rates (same device) - Email → Variable (different devices) #### Implementation ##### Step 1: Create Links Add deep link parameters to your link URLs: ```bash ### Product link curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "template_123", "originalUrl": "https://example.com/products/headphones?productId=456&category=audio", "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.app" }' ``` **Deep link parameters:** Any query parameter in `originalUrl` becomes available in app. ##### Step 2: Integrate SDK Install and initialize the SDK: ```typescript import LinkForty from '@linkforty/mobile-sdk-react-native'; // Initialize on app launch LinkForty.init({ baseUrl: 'https://go.yourdomain.com', appToken: 'at_your_app_token', // recommended for Cloud }); ``` See [SDK Integration Guide](https://docs.linkforty.com/guides/attribution/sdk-integration/) for full setup. ##### Step 3: Check for Deep Link On first app launch: ```typescript import { useEffect } from 'react'; import LinkForty from '@linkforty/mobile-sdk-react-native'; import { useNavigation } from '@react-navigation/native'; function App() { const navigation = useNavigation(); useEffect(() => { // Fires on a new install attributed to a link; null for organic installs LinkForty.onDeferredDeepLink((data) => { if (data) { console.log('Deep link data:', data); handleDeepLink(data); } }); }, []); const handleDeepLink = (data) => { const params = data.customParameters ?? {}; if (params.productId) { navigation.navigate('Product', { id: params.productId }); } else if (params.category) { navigation.navigate('Category', { slug: params.category }); } else if (data.deepLinkPath) { // Route using the link's deep link path navigation.navigate(data.deepLinkPath); } }; return ( // Your app ); } ``` ##### Step 4: Test **Test flow:** 1. Create test link with query parameters 2. Copy link to notes app on test device 3. **Uninstall app** from device 4. Click link from notes 5. Install app from store 6. Open app 7. Check if navigated to correct content **Expected logs:** ``` [LinkForty] Checking for deep link... [LinkForty] Match found! [LinkForty] Deep link data: { productId: "456", category: "audio", utm_source: "test" } [App] Navigating to product 456 ``` #### URL Parameter Passthrough You do not need a separate link for every piece of content. Add query parameters to one link when you share it, and they reach the app after the install. ``` https://go.example.com/content?slug=titanic https://go.example.com/content?slug=spiderman-homecoming ``` Both are the same link. The value you appended arrives in `customParameters`, whether the app was just installed or was already on the device: ```typescript function openContent(data) { const slug = data?.customParameters?.slug; if (slug) { navigation.navigate('Content', { slug }); } } // New install, matched back to the click LinkForty.onDeferredDeepLink(openContent); // App already installed — the OS hands the link straight to the app LinkForty.onDeepLink((url, data) => openContent(data)); ``` The two entry points are different moments, but the value is read the same way in both. You do not need to parse the URL yourself. On a direct open the SDK merges the parameters from the tapped URL into the payload it resolves for the link. If a name appears in both, the URL wins — see [Precedence](#precedence). ##### SDK Versions Delivering URL parameters on a **direct open** requires: | SDK | Minimum version | |---|---| | React Native | 1.6.1 | | Expo | 1.6.1 | | iOS | 1.4.1 | | Android | 1.3.1 | | Flutter | 0.2.1 | Earlier versions still receive parameters on the deferred path. On a direct open they fall back to the link's stored configuration, so `customParameters` will not carry what was appended to the URL. ##### Reserved Parameters Some names are consumed by LinkForty and never reach your app, because they already do a job. Matching is case-insensitive, and the first two are matched **by prefix** — every name starting with `utm_` or `fp_` is reserved, not just the ones listed here: | Name | Used for | |---|---| | `utm_*` — e.g. `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` | campaign reporting — see [Campaigns](https://docs.linkforty.com/guides/analytics/campaigns/) | | `fp_*` — e.g. `fp_tz`, `fp_lang`, `fp_sw`, `fp_sh`, `fp_platform`, `fp_pv` | the device fingerprint used to match the install | | `lf_click` (exact) | the click id LinkForty adds to your destination URL | Anything else is passed through. Avoid the `utm_` and `fp_` prefixes for your own values. ##### Limits - **16 parameters** per link. Beyond that, the extras are dropped. - **256 characters** per value. Longer values are **silently truncated**, not rejected — the link still works and the click is still recorded, so check the length yourself if a value might be long. ##### Precedence A parameter on the URL beats one configured on the link itself. A link set up with `slug=default` that is shared as `?slug=titanic` delivers `titanic` — what the sharer put on the URL is more specific than the link's stored configuration. ##### What Does Not Come Through - **Bot traffic.** Clicks classified as bots record no parameters at all. - **Values added after the click.** Parameters are captured at click time. Changing the URL later does not change what an earlier click stored. - **The fragment.** Anything after `#` never reaches the server. **Warning: Validate before you route** These values come from a public URL and anyone can set them to anything. Treat them as untrusted input: check that a value is one you expect before routing on it, and never pass one straight into a URL loader, a database query, or a privileged navigation. #### Use Cases ##### E-commerce Product Links **Link:** Instagram ad for wireless headphones ```json { "originalUrl": "https://shop.example.com/products/wireless-headphones?productId=789", "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.shop" } ``` **App flow:** ```typescript const data = await LinkForty.getInstallData(); // { productId: "789" } navigation.navigate('Product', { id: data.productId }); ``` **User sees:** Wireless headphones product page immediately after install. ##### Content Deep Links **Link:** Email with article link ```json { "originalUrl": "https://blog.example.com/articles/how-to-cook-pasta?articleId=123§ion=recipes", "iosUrl": "https://apps.apple.com/app/id456", "androidUrl": "https://play.google.com/store/apps/details?id=com.blog" } ``` **App flow:** ```typescript const data = await LinkForty.getInstallData(); // { articleId: "123", section: "recipes" } navigation.navigate('Article', { id: data.articleId }); ``` **User sees:** Article about cooking pasta. ##### Referral Programs **Link:** Friend's referral link ```json { "originalUrl": "https://example.com/referral/john-doe?referrerId=user_456&reward=10", "iosUrl": "https://apps.apple.com/app/id789", "androidUrl": "https://play.google.com/store/apps/details?id=com.app" } ``` **App flow:** ```typescript const data = await LinkForty.getInstallData(); // { referrerId: "user_456", reward: "10" } // Award referral credit await awardReferralCredit(data.referrerId, data.reward); // Show success message showToast(`You received $${data.reward} credit from ${getReferrerName(data.referrerId)}!`); ``` **User sees:** Welcome message with referral credit. ##### Promotional Campaigns **Link:** Limited-time offer ```json { "originalUrl": "https://example.com/promo/spring-sale?couponCode=SPRING20&discount=20", "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.app" } ``` **App flow:** ```typescript const data = await LinkForty.getInstallData(); // { couponCode: "SPRING20", discount: "20" } // Auto-apply coupon await applyCoupon(data.couponCode); // Navigate to sale navigation.navigate('Sale', { discount: data.discount }); ``` **User sees:** Sale page with coupon already applied. #### Advanced Techniques ##### Multi-Step Onboarding Defer deep link until after onboarding: ```typescript const [onboardingComplete, setOnboardingComplete] = useState(false); const [pendingDeepLink, setPendingDeepLink] = useState(null); useEffect(() => { checkDeepLink(); }, []); const checkDeepLink = async () => { const data = await LinkForty.getInstallData(); if (data) { setPendingDeepLink(data); } }; const handleOnboardingComplete = () => { setOnboardingComplete(true); if (pendingDeepLink) { handleDeepLink(pendingDeepLink); } }; ``` ##### Conditional Navigation Navigate based on user state: ```typescript const handleDeepLink = async (data) => { const user = await getCurrentUser(); if (!user) { // Not logged in - show login, then navigate navigation.navigate('Login', { redirectTo: 'Product', redirectParams: { id: data.productId } }); } else { // Logged in - navigate directly navigation.navigate('Product', { id: data.productId }); } }; ``` ##### Deep Link Fallbacks Handle missing or invalid data: ```typescript const handleDeepLink = async (data) => { if (data.productId) { // Validate product exists const product = await api.getProduct(data.productId); if (product) { navigation.navigate('Product', { id: data.productId }); } else { // Invalid product - fallback to category if (data.category) { navigation.navigate('Category', { slug: data.category }); } else { // No valid destination - go to home navigation.navigate('Home'); } } } }; ``` ##### Deep Link Analytics Track deep link usage: ```typescript const handleDeepLink = async (data) => { // Track deep link event await LinkForty.trackEvent({ eventName: 'deep_link_opened', properties: { productId: data.productId, utm_source: data.utm_source, utm_campaign: data.utm_campaign, hasAccount: !!user } }); // Navigate navigation.navigate('Product', { id: data.productId }); }; ``` #### Troubleshooting ##### Deep Link Data Returns Null **Symptom:** the deferred deep link handler fires with `null` (or `getInstallData()` returns `null`) despite the user clicking the link. **Debug steps:** 1. **Check attribution window:** ```bash # View link details curl https://api.linkforty.com/api/links/abc123 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" # Check attribution_window_hours value ``` 2. **Enable debug logging:** ```typescript LinkForty.init({ apiKey: API_KEY, debug: true }); ``` 3. **Check click was recorded:** - Go to Analytics → Links → Select link - Verify click appears in list - Note timestamp 4. **Check install timing:** - Compare click timestamp to install time - If >attribution window → No match expected 5. **Verify network conditions:** - Same WiFi/cellular for click and install? - VPN enabled? - iOS Private Relay active? ##### Low Match Rates **Expected:** 70-80% for normal conditions **If lower (e.g., 40-50%):** 1. **Increase attribution window:** - Try 14 days instead of 7 - May capture more delayed installs 2. **Check traffic source:** - In-app browsers → Good (70-80%) - Web browsers → Lower (60-70%) - Email links → Variable (different devices) 3. **Platform-specific issues:** - iOS 14+ privacy features reduce accuracy - Android: Check Google Play Install Referrer integration 4. **Geographic factors:** - Users in regions with unstable IPs - Frequent VPN usage ##### Incorrect Content Loaded **Symptom:** Deep link data has wrong product ID. **Possible causes:** 1. **False positive match:** - User clicked different link before - Fingerprints happened to match - Solution: Shorter attribution window 2. **Query parameter typo:** - Check link URL has correct parameters - Verify parameter names match app code 3. **Cached data:** - Old deep link data cached - Solution: Clear app data and retest #### Best Practices ##### 1. Always Provide Deep Link Parameters Include useful data in every link: **Good:** ``` originalUrl: "https://example.com/product?productId=123&category=electronics" ``` **Bad:** ``` originalUrl: "https://example.com/product" ``` ##### 2. Use Meaningful Parameter Names Be explicit: **Good:** ``` ?productId=123&categorySlug=electronics ``` **Bad:** ``` ?id=123&cat=elec ``` ##### 3. Handle Missing Parameters Always provide fallbacks: ```typescript const data = await LinkForty.getInstallData(); if (data?.productId) { navigation.navigate('Product', { id: data.productId }); } else if (data?.category) { navigation.navigate('Category', { slug: data.category }); } else { navigation.navigate('Home'); } ``` ##### 4. Test on Real Devices Fingerprint matching doesn't work in simulators. Always test on physical devices. ##### 5. Set Appropriate Attribution Windows Match window to expected user behavior: - Impulse products: 1-3 days - Considered purchases: 7-14 days - Referrals: 30-90 days ##### 6. Track Deep Link Performance Monitor conversion rates: ```typescript // Track when deep link opens await LinkForty.trackEvent({ eventName: 'deep_link_opened', properties: { productId: data.productId } }); // Track if user converts await LinkForty.trackEvent({ eventName: 'purchase', value: 29.99, properties: { productId: data.productId } }); ``` **View in dashboard:** See conversion rate from deep link open → purchase. #### Next Steps - [SDK Integration](https://docs.linkforty.com/guides/attribution/sdk-integration/) - Set up mobile SDK - [React Native SDK](https://docs.linkforty.com/sdks/react-native/) - Complete React Native guide - [Attribution Windows](https://docs.linkforty.com/guides/attribution/attribution-windows/) - Configure windows - [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) - Create deep links - [Analytics](https://docs.linkforty.com/guides/analytics/dashboard/) - Monitor performance #### Further Reading - [Attribution Windows](https://docs.linkforty.com/guides/attribution/attribution-windows/) - Attribution concepts - [QR Codes](https://docs.linkforty.com/guides/links/qr-codes/) - Offline-to-online deep linking --- ## Attribution Windows Source: https://docs.linkforty.com/guides/attribution/attribution-windows/ Attribution windows determine how long clicks remain valid for matching to app installs. LinkForty gives you per-link control that competitors charge thousands for. #### What is an Attribution Window? An attribution window is the time period during which a click can be matched to an app install. **Example Flow:** 1. **Monday 10:00 AM** - User clicks your short link - LinkForty creates device fingerprint - Stores click event with timestamp 2. **Wednesday 3:00 PM** - User installs your app (65 hours later) - App opens and SDK initializes - SDK sends fingerprint to LinkForty 3. **Attribution Check** - Time since click: 65 hours - Attribution window: 168 hours (7 days) - **Result: Match found** (65h < 168h) 4. **Deep Link Delivered** - SDK receives original link parameters - App navigates user to specific content - Install attributed to your campaign If the user had installed on **Sunday** (160 hours later), still within 168h window — match. If the user had installed **next Monday** (168+ hours later), exceeds window — no match. #### Two Windows: Install vs Event {#event-attribution-window} LinkForty has **two** independent attribution windows, both configurable per link: | Window | What it bounds | Field | |--------|----------------|-------| | **Install attribution window** | How long a click stays eligible to match an **install** | `attributionWindowHours` | | **Event (conversion) attribution window** | How long after the click an **in-app event** still attributes to the link | `eventAttributionWindowHours` | Everything above on this page describes the **install** window. The **event** window governs the down-funnel: once an install is attributed, how long do that install's subsequent in-app events (purchases, subscriptions, level-ups) keep counting toward the originating link? **Example:** A user clicks a Black Friday link and installs the next day (within the install window). Three weeks later they make a purchase. Whether that purchase is credited to the Black Friday link depends on the **event** attribution window, not the install window. ##### Defaults and fallback The event window resolves in this order: 1. The link's own `eventAttributionWindowHours`, if set 2. Otherwise the link's template default (`defaultEventAttributionWindowHours`) 3. Otherwise the **system default of 168 hours (7 days)** Unlike the install window, the event window is left unset (`null`) by default so the system default applies at read time. The same **1 to 2,160 hour** range applies. ```json { "title": "Subscription Campaign", "attributionWindowHours": 168, // install must happen within 7 days of click "eventAttributionWindowHours": 720 // events count for 30 days after the click } ``` ##### How it's applied When App Analytics rolls up [down-funnel events](https://docs.linkforty.com/guides/analytics/app-analytics/#down-funnel-events), each in-app event is included only if it occurred within the attributed link's event window of the original click. Events outside the window still exist in your data — they just aren't credited to that link's funnel. `screen_view` events from [automatic screen tracking](https://docs.linkforty.com/sdks/react-native/#automatic-screen-tracking) are excluded from this conversion rollup. **Tip: Match the event window to your monetization cycle** Set a longer event window (30–90 days) for apps where revenue arrives well after install — subscriptions, considered purchases, B2B. Keep it short for impulse-driven funnels where a late purchase probably wasn't driven by the original link. #### Why Attribution Windows Matter ##### Conversion Rate Impact Longer windows catch more installs but may reduce accuracy: | Window Length | Typical Match Rate | Accuracy | Best For | |---------------|-------------------|----------|----------| | **1 hour** | 5-10% | Very High | Immediate actions | | **24 hours** | 15-25% | High | Direct response | | **7 days** | 35-50% | Balanced | Industry standard | | **30 days** | 45-60% | Lower | Brand awareness | | **90 days** | 50-70% | Lowest | Long consideration | **The Trade-off:** - Shorter windows = Higher confidence in attribution, fewer matches - Longer windows = More matches, but some may be coincidental ##### Business Impact **E-commerce App (7-day window)** - 1,000 clicks from Instagram ad - 350 installs within 7 days (35% conversion) - Cost: $500 ad spend - **CPI: $1.43** **Same campaign with 1-hour window:** - 1,000 clicks - 75 installs within 1 hour (7.5% conversion) - Cost: $500 ad spend - **CPI: $6.67** (looks way more expensive!) Shorter windows under-report true performance. Longer windows may over-attribute. #### LinkForty's Competitive Advantage ##### Per-Link Attribution Windows **LinkForty:** Configure attribution window for **every individual link** ```json { "title": "Flash Sale - 24h Only", "attributionWindowHours": 24, "originalUrl": "https://example.com/flash-sale" } ``` **AppsFlyer:** Organization-wide setting only. One size fits all campaigns. **Branch:** Requires SDK changes to modify windows. **Impact:** You can optimize each campaign independently. ##### Granular Control (1 to 2,160 Hours) **LinkForty:** Any value from 1 hour to 90 days (2,160 hours) ```json { "attributionWindowHours": 127 // Exactly 127 hours } ``` **AppsFlyer:** Fixed presets (1, 3, 7, 14, 30 days) **Adjust:** Fixed presets only (1, 7, 30 days) **Impact:** Match your window to customer behavior patterns, not platform limitations. ##### Platform Availability **LinkForty:** - Available on all tiers (Free, Pro, Self-hosted) - Full control over attribution windows - No restrictions or approvals needed **Other Platforms:** - Often limited to enterprise tiers - May require account manager approval - Typically organization-wide settings only #### Choosing the Right Window ##### Campaign Type Matrix | Campaign Type | Recommended Window | Reasoning | |---------------|-------------------|-----------| | **Flash Sales** | 1-24 hours | Short decision cycle, immediate action | | **Direct Response Ads** | 24-72 hours | Users install quickly or not at all | | **Product Launches** | 7-14 days | Need time for awareness to build | | **Content Marketing** | 7-30 days | Longer consideration, education needed | | **Referral Programs** | 30-90 days | Friend recommendations take time | | **Brand Awareness** | 14-30 days | Building familiarity over time | | **Retargeting** | 14-30 days | Re-engaging previous visitors | | **Influencer Campaigns** | 7-14 days | Audience acts within days | | **Email Campaigns** | 3-7 days | Readers act within week | | **SMS/Push** | 1-6 hours | Immediate channel | | **Print/QR Codes** | 7-30 days | Offline-to-online delay | ##### Industry Benchmarks | Industry | Typical Decision Cycle | Recommended Window | |----------|----------------------|-------------------| | **Food Delivery** | Minutes to hours | 1-24 hours | | **E-commerce** | Hours to days | 3-7 days | | **Finance Apps** | Days to weeks | 7-30 days | | **Dating Apps** | Hours to days | 24-72 hours | | **Productivity Tools** | Days to weeks | 7-14 days | | **Games** | Minutes to days | 24-72 hours | | **Health & Fitness** | Days to weeks | 7-30 days | | **Real Estate** | Weeks to months | 30-90 days | | **B2B SaaS** | Weeks to months | 30-90 days | | **Travel** | Days to weeks | 14-30 days | ##### Platform Behavior Patterns **iOS Users:** - Longer consideration time - More research before install - Recommended: +3-7 days vs Android **Android Users:** - Faster install decisions - More impulsive downloads - Recommended: Shorter windows **Example:** ```json // iOS link { "title": "iOS Campaign", "iosUrl": "https://apps.apple.com/app/id123", "attributionWindowHours": 336 // 14 days } // Android link { "title": "Android Campaign", "androidUrl": "https://play.google.com/store/apps/details?id=com.app", "attributionWindowHours": 168 // 7 days } ``` #### Setting Attribution Windows ##### Via Dashboard **Creating New Link:** 1. Click **"Create Link"** 2. Fill in basic details 3. Find **"Attribution Window"** dropdown 4. Select from presets: - 1 hour (privacy-focused) - 24 hours (direct response) - 72 hours (short campaigns) - **168 hours (7 days) ← Default** - 336 hours (14 days) - 720 hours (30 days) - 2160 hours (90 days) 5. Click **"Create Link"** **Editing Existing Link:** 1. Go to **Links** page 2. Click link you want to edit 3. Click **"Edit"** button 4. Change **"Attribution Window"** value 5. Click **"Save"** **Note:** Changing attribution window applies to new clicks only. Existing fingerprints retain original window. ##### Via API **Creating Link with Custom Window:** ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/product", "attributionWindowHours": 336 }' ``` **Updating Window:** ```bash curl -X PUT https://api.linkforty.com/api/links/abc123 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "attributionWindowHours": 720 }' ``` **Bulk Update:** ```bash curl -X POST https://api.linkforty.com/api/links/bulk-update \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "linkIds": [ "link_abc123", "link_def456", "link_ghi789" ], "updates": { "attributionWindowHours": 168 } }' ``` ##### Via Link Templates Set default windows for all links from a template: ```bash curl -X POST https://api.linkforty.com/api/templates \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Social Media Template", "attributionWindowHours": 168, "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.app" }' ``` All links created from this template inherit 168-hour window (overridable). #### Advanced Strategies ##### Dynamic Windows by Traffic Source Different channels need different windows: ```javascript // Programmatic link creation const ATTRIBUTION_WINDOWS = { sms: 6, // 6 hours - immediate channel push: 12, // 12 hours - notification urgency email: 168, // 7 days - read at leisure social: 168, // 7 days - discovery browsing search: 72, // 3 days - high intent display: 720, // 30 days - brand awareness influencer: 336 // 14 days - trust building }; function createCampaignLink(source, url) { return fetch('https://api.linkforty.com/api/links', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ templateId: TEMPLATE_ID, originalUrl: url, attributionWindowHours: ATTRIBUTION_WINDOWS[source], utmParameters: { utm_source: source } }) }); } ``` ##### A/B Testing Attribution Windows Test different windows to optimize for your audience: **Test Setup:** ```json // Variant A: Short window (high confidence) { "title": "Instagram Ad - Variant A", "customCode": "insta-test-a", "attributionWindowHours": 72, "utmContent": "variant-a" } // Variant B: Long window (high volume) { "title": "Instagram Ad - Variant B", "customCode": "insta-test-b", "attributionWindowHours": 336, "utmContent": "variant-b" } ``` **Analysis:** ```sql -- Compare conversion rates SELECT utm_content as variant, COUNT(DISTINCT click_id) as clicks, COUNT(DISTINCT CASE WHEN installed_at IS NOT NULL THEN user_id END) as installs, ROUND(100.0 * COUNT(DISTINCT CASE WHEN installed_at IS NOT NULL THEN user_id END) / COUNT(DISTINCT click_id), 2) as conversion_rate, AVG(EXTRACT(EPOCH FROM (installed_at - clicked_at))/3600) as avg_time_to_install_hours FROM click_events WHERE utm_campaign = 'instagram-test' GROUP BY utm_content; ``` **Decision criteria:** - If similar conversion rates → Use shorter window (higher confidence) - If significantly different → Use longer window (captures more value) - Monitor `avg_time_to_install_hours` to set optimal window ##### Seasonal Adjustments Adjust windows based on seasonality: ```javascript function getSeasonalWindow(baseWindow) { const month = new Date().getMonth(); // Holiday shopping (Nov-Dec): Users delay installs, shopping around if (month >= 10) { return baseWindow * 1.5; // +50% longer } // Summer (Jun-Aug): Faster decisions, vacation browsing if (month >= 5 && month <= 7) { return baseWindow * 0.75; // -25% shorter } // Back to school (Aug-Sep): Research-heavy if (month >= 7 && month <= 8) { return baseWindow * 1.25; // +25% longer } return baseWindow; } // Use: getSeasonalWindow(168) → 252 hours in November ``` ##### Fraud Prevention with Short Windows Prevent attribution fraud with aggressive windows: **Problem:** Malicious actors click links, install later, claim referral rewards **Solution:** Short windows reduce fraud window ```json { "title": "Referral Program Link", "attributionWindowHours": 24, // Must install within 24h "utmSource": "referral" } ``` **Additional protection:** - Require email verification before reward - Check fingerprint quality score - Validate IP address isn't VPN/proxy - Monitor for suspicious patterns #### Monitoring Attribution Performance ##### Dashboard Analytics **View attribution metrics:** 1. Go to **Analytics** → **Links** 2. Select your link 3. View **"Attribution Performance"** section: - Total clicks - Attributed installs - Average time to install - Attribution rate (% of clicks that install) **Time-to-install distribution:** | Time Range | Installs | Percentage | |------------|----------|------------| | 0-1 hours | 45 | 15% | | 1-24 hours | 90 | 30% | | 1-3 days | 75 | 25% | | 3-7 days | 60 | 20% | | 7-14 days | 30 | 10% | **Insight:** 70% install within 24 hours → Could reduce window from 14 days to 3 days without losing much volume. ##### API Analytics ```bash curl https://api.linkforty.com/api/analytics/links/abc123 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Response:** ```json { "linkId": "abc123", "totalClicks": 1000, "attributedInstalls": 350, "attributionRate": 0.35, "avgTimeToInstallHours": 42.5, "timeToInstallDistribution": { "0-1h": 150, "1-24h": 100, "1-3d": 60, "3-7d": 30, "7-14d": 10 }, "attributionWindowHours": 168 } ``` ##### Optimization Recommendations **If `avgTimeToInstallHours` is much less than `attributionWindowHours`:** ``` avg: 42 hours window: 336 hours (14 days) → Recommendation: Reduce window to 72 hours (saves server resources) ``` **If many installs near end of window:** ``` 7-14d range: 30% of installs window: 336 hours (14 days) → Recommendation: Increase window to 720 hours (30 days) to capture more ``` #### Comparison with Competitors | Feature | LinkForty | AppsFlyer | Branch | Adjust | |---------|-----------|-----------|---------|--------| | **Per-Link Windows** | Free | Enterprise only | Paid plans | Org-level only | | **Granular Control** | 1-2160 hours | Fixed presets | Fixed presets | Fixed presets | | **Tier Availability** | **All tiers** | **Enterprise** | **Paid** | **Enterprise** | | **API Updates** | Instant | Instant | Delayed | SDK required | | **Bulk Updates** | Yes | Yes (Enterprise) | No | No | | **Historical Changes** | Planned | Yes | Limited | Limited | | **Template Defaults** | Yes | Yes (Enterprise) | No | No | | **Window Range** | **1h - 90d** | 1d - 30d | 1d - 30d | 1d - 30d | #### Best Practices Summary 1. **Start with 7 days (168 hours)** - Industry standard, balanced approach 2. **Match window to customer journey:** - Impulse purchases → 24-72 hours - Considered purchases → 7-30 days - Enterprise/B2B → 30-90 days 3. **Monitor time-to-install metrics** - Adjust windows based on actual behavior 4. **Use shorter windows for:** - Fraud-prone campaigns (referrals) - Immediate action campaigns (flash sales) - Privacy-conscious users 5. **Use longer windows for:** - Brand awareness campaigns - Expensive products (high consideration) - Referral programs (trust building) 6. **Platform-specific windows:** - iOS: +3-7 days vs Android - Web: Shorter (users use mobile more) 7. **Test and optimize:** - A/B test different windows - Analyze conversion rates vs confidence - Adjust based on data, not assumptions 8. **Template-based defaults:** - Create templates for common use cases - Override for specific campaigns #### Troubleshooting ##### Low Attribution Rates **Symptom:** Very few installs attributed despite high click volume **Possible Causes:** 1. **Attribution window too short** - Solution: Increase window to 7-14 days 2. **Users installing much later than expected** - Check analytics: "Time to Install Distribution" - Increase window to cover 80% of installs 3. **Fingerprint matching issues** - See [Deferred Deep Linking](https://docs.linkforty.com/guides/attribution/deferred-deep-linking/) for details on fingerprint matching ##### Over-Attribution **Symptom:** Attribution rate >70% seems unrealistic **Possible Causes:** 1. **Attribution window too long** - Solution: Reduce window to increase confidence 2. **Coincidental installs being attributed** - Users may have discovered app elsewhere - Reduce window to 1-3 days for high-confidence only ##### Installs Not Being Attributed **Symptom:** Users report installing from your link, but no attribution **Possible Causes:** 1. **Installed after attribution window expired** - Check `clicked_at` vs `installed_at` timestamps - Increase window if pattern detected 2. **SDK not initialized properly** - See [SDK Integration Guide](https://docs.linkforty.com/guides/attribution/sdk-integration/) 3. **Different device/network for click vs install** - Fingerprint won't match (expected behavior) - Consider device ID matching (requires SDK) #### Next Steps - [SDK Integration](https://docs.linkforty.com/guides/attribution/sdk-integration/) - Set up mobile SDK for attribution - [Deferred Deep Linking](https://docs.linkforty.com/guides/attribution/deferred-deep-linking/) - How attribution works under the hood - [Analytics Dashboard](https://docs.linkforty.com/guides/analytics/dashboard/) - Monitor attribution performance - [Link Templates](https://docs.linkforty.com/guides/links/link-templates/) - Set default windows per template #### API Reference - [Create Link](https://docs.linkforty.com/api/endpoints/links/create-link/) - [Update Link](https://docs.linkforty.com/api/endpoints/links/update-link/) - [Bulk Update Links](https://docs.linkforty.com/api/endpoints/links/bulk-operations/) - [Link Analytics](https://docs.linkforty.com/api/endpoints/analytics/link-analytics/) --- ## Mobile SDK Integration Guide - LinkForty Source: https://docs.linkforty.com/guides/attribution/sdk-integration/ Integrate a LinkForty mobile SDK to enable deferred deep linking, attribution tracking, and in-app event analytics. This page is a cross-platform overview — each SDK's complete API lives on its own page. #### What the SDK does - **Deferred deep linking** — route new users to specific content after install - **Direct deep linking** — handle links when the app is already installed - **Install attribution** — match installs to the link click that drove them (fingerprint matching, no device IDs required) - **Event tracking** — log in-app events and revenue tied to attribution data #### Prerequisites 1. A LinkForty account (Cloud) or a running [self-hosted Core](https://docs.linkforty.com/getting-started/self-hosting/) server 2. Your server's base URL (e.g. `https://go.yourdomain.com`) 3. For Cloud: your workspace **App Token** (`at_…`, found under Workspace Settings → App Token) — recommended so organic installs are attributed 4. *(Optional)* an **API key** (`dl_…`, from Settings → API Keys) — only needed if you create links programmatically from the app #### Platform SDKs Pick your platform and follow its full guide: | Platform | Install | Full guide | |----------|---------|------------| | **React Native** | `npm install @linkforty/mobile-sdk-react-native` | [React Native SDK](https://docs.linkforty.com/sdks/react-native/) | | **Expo** | `npx expo install @linkforty/mobile-sdk-expo` | [Expo SDK](https://docs.linkforty.com/sdks/expo/) | | **iOS** (Swift) | `pod 'LinkFortySDK', '~> 1.0'` (or Swift Package Manager) | [iOS SDK](https://docs.linkforty.com/sdks/ios/) | | **Android** (Kotlin) | `implementation("com.linkforty:sdk:1.3.0")` | [Android SDK](https://docs.linkforty.com/sdks/android/) | | **Flutter** | `linkforty_flutter` in `pubspec.yaml` | [Flutter SDK](https://docs.linkforty.com/sdks/flutter/) | All SDKs share the same flow: **initialize → handle the deferred deep link → track events**. #### How integration works ##### 1. Initialize at app launch Initialize the SDK as early as possible with your server `baseUrl` and (for Cloud) your `appToken`. **React Native:** ```typescript import LinkForty from '@linkforty/mobile-sdk-react-native'; LinkForty.init({ baseUrl: 'https://go.yourdomain.com', appToken: 'at_your_app_token', // recommended for Cloud apiKey: 'dl_your_api_key', // only if you create links from the app debug: __DEV__, }); ``` **iOS (Swift):** ```swift import LinkFortySDK let config = LinkFortyConfig( baseURL: "https://go.yourdomain.com", appToken: "at_your_app_token" ) try await LinkForty.shared.initialize(config: config) ``` **Android (Kotlin):** ```kotlin import com.linkforty.sdk.LinkForty import com.linkforty.sdk.LinkFortyConfig val config = LinkFortyConfig( baseUrl = "https://go.yourdomain.com", appToken = "at_your_app_token" ) LinkForty.initialize(this, config) ``` **Flutter:** ```dart import 'package:linkforty_flutter/link_forty.dart'; import 'package:linkforty_flutter/models/link_forty_config.dart'; final config = LinkFortyConfig( baseUrl: 'https://go.yourdomain.com', appToken: 'at_your_app_token', ); await LinkForty.initialize(config: config); ``` ##### 2. Handle the deferred deep link Register a deferred deep link handler. On a new install attributed to a link, it fires with the link's data so you can route the user to the right content. It receives `null` for organic installs. **React Native:** ```typescript LinkForty.onDeferredDeepLink((data) => { if (data) { // e.g. navigate using data.deepLinkPath / data.customParameters navigateToContent(data); } }); ``` **iOS (Swift):** ```swift LinkForty.shared.onDeferredDeepLink { deepLinkData in guard let data = deepLinkData else { return } // organic install navigateToContent(data) } ``` **Android (Kotlin):** ```kotlin LinkForty.shared.onDeferredDeepLink { deepLinkData -> deepLinkData?.let { navigateToContent(it) } } ``` **Flutter:** ```dart LinkForty.instance.onDeferredDeepLink((deepLinkData) { if (deepLinkData != null) { navigateToContent(deepLinkData); } }); ``` Use the corresponding **direct** deep link handler (e.g. `onDeepLink`) to route links opened while the app is already installed — see your platform's SDK page. ##### 3. Track in-app events Track the actions that matter (signups, purchases) so they're attributed to the originating link. Use the dedicated revenue method for monetary events so amounts aggregate consistently across SDKs. ```typescript // React Native LinkForty.trackEvent('signup', { method: 'email' }); LinkForty.trackRevenue(29.99, 'USD', { productId: 'sku-123', orderId: 'order-456' }); ``` Events appear in the dashboard under **Analytics → Events**. See [Events & Conversions](https://docs.linkforty.com/guides/analytics/events/) and your platform's SDK page for the exact signatures. #### Core concepts ##### Deferred deep linking Routes new users to specific content after they install: 1. User taps a link (e.g. a product page) 2. App isn't installed → user goes to the App Store / Play Store 3. User installs and opens the app 4. The SDK retrieves the attributed link data 5. Your app navigates to the original content ##### Attribution LinkForty matches installs to clicks using device fingerprinting (typically 70%+ accuracy without device IDs), within the link's [attribution window](https://docs.linkforty.com/guides/attribution/attribution-windows/). View attributed installs under **Analytics → Installs**, or per link. ##### Event tracking In-app events are tied to the install's attribution, so a purchase made days after install can still be credited to the link and campaign that drove it (within the [event attribution window](https://docs.linkforty.com/guides/attribution/attribution-windows/#event-attribution-window)). #### Testing your integration Fingerprint matching is unreliable on simulators/emulators — **test on a real device**: 1. **Uninstall** the app from the device 2. **Tap a test link** that points to your app 3. **Install** the app from the store (or your dev build) 4. **Open** the app 5. Verify the deferred deep link handler fires with the expected data (enable `debug` to see logs) 6. Confirm the install appears under **Analytics → Installs** #### iOS Universal Links Universal Links let a tapped LinkForty URL open your app directly (instead of the browser) when it's installed. Two pieces are required: 1. **Associated Domains in your app.** In Xcode, add the **Associated Domains** capability to your target and list your link domain: ``` applinks:go.yourdomain.com ``` 2. **The Apple App Site Association (AASA) file on your server.** LinkForty serves this automatically at `/.well-known/apple-app-site-association` once you provide your Apple Team ID and bundle ID. - **LinkForty Cloud:** set these in your workspace's app configuration. - **Self-hosted Core:** set the `IOS_TEAM_ID` and `IOS_BUNDLE_ID` [environment variables](https://docs.linkforty.com/reference/environment-variables/#mobile-app-links). If they're unset, the AASA endpoint returns a 404. Verify the file is served (no `.json` extension, `application/json` content type, over HTTPS): ```bash curl https://go.yourdomain.com/.well-known/apple-app-site-association ``` See also [Universal Links vs App Links](https://docs.linkforty.com/learn/universal-links-vs-app-links/). #### Android App Links Android App Links are the equivalent mechanism for opening your app from a verified domain. Two pieces are required: 1. **An intent filter in your `AndroidManifest.xml`** with `android:autoVerify="true"` for your link host (see the [React Native SDK guide](https://docs.linkforty.com/sdks/react-native/#platform-configuration) for a full example). 2. **The Digital Asset Links file on your server.** LinkForty serves this automatically at `/.well-known/assetlinks.json` once you provide your package name and signing-certificate fingerprints. - **LinkForty Cloud:** set these in your workspace's app configuration. - **Self-hosted Core:** set the `ANDROID_PACKAGE_NAME` and `ANDROID_SHA256_FINGERPRINTS` (comma-separated) [environment variables](https://docs.linkforty.com/reference/environment-variables/#mobile-app-links). If they're unset, the `assetlinks.json` endpoint returns a 404. Verify the file is served: ```bash curl https://go.yourdomain.com/.well-known/assetlinks.json ``` See also [Universal Links vs App Links](https://docs.linkforty.com/learn/universal-links-vs-app-links/). #### Best practices - **Initialize early** — in your app entry point, before navigation, so the deferred deep link is ready on first launch. - **Test on real devices** — fingerprint matching is unreliable on simulators/emulators. - **Keep secrets appropriately** — the `appToken` (`at_…`) is safe to ship in your app bundle; an API key (`dl_…`) is a secret, so only embed it if you truly need in-app link creation, and load it from a secure config. - **Track meaningful events** — focus on value events (signup, purchase) rather than every interaction; use the revenue method for money. - **Validate deep link data** — don't trust it blindly; verify IDs exist before navigating. #### Troubleshooting ##### Deferred deep link handler doesn't fire - The attribution window may have expired (user installed too long after clicking) - Different network between click and install (VPN, iOS Private Relay) can prevent a fingerprint match - Ensure the SDK is initialized **before** registering the handler - Enable `debug` to see detailed logs, and test on a real device ##### Events not appearing - Events require a successful install report — confirm the SDK has an install ID - Check connectivity and your `baseUrl` - Allow 1–2 minutes for events to appear in the dashboard ##### Lower-than-expected attribution - Increase the [attribution window](https://docs.linkforty.com/guides/attribution/attribution-windows/) if users install later than it allows - VPN/proxy usage and cellular↔Wi-Fi switching reduce match accuracy - Always measure on real devices #### Next Steps - [React Native SDK](https://docs.linkforty.com/sdks/react-native/) - Complete React Native guide - [iOS SDK](https://docs.linkforty.com/sdks/ios/) - Native iOS integration - [Android SDK](https://docs.linkforty.com/sdks/android/) - Native Android integration - [Flutter SDK](https://docs.linkforty.com/sdks/flutter/) - Flutter integration - [Deferred Deep Linking](https://docs.linkforty.com/guides/attribution/deferred-deep-linking/) - How it works - [Analytics](https://docs.linkforty.com/guides/analytics/dashboard/) - View attribution data - [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) - Create deep links #### Support - [Report Issues](https://github.com/linkforty/core/issues) - Email: inbox@linkforty.com --- ## SDK Health Check Source: https://docs.linkforty.com/guides/attribution/sdk-health/ The **SDK Doctor** tells you, at a glance, whether your mobile SDK is installed correctly and actually reporting data — so you find integration problems before they cost you attribution. #### Why it exists A silent SDK is the most expensive bug in attribution: installs happen, but nothing reaches LinkForty, and you don't notice until a campaign report comes back empty. The SDK Doctor watches the live signal — installs and in-app events — and surfaces the health of every SDK version your app is shipping. #### Where to find it A **health badge** sits in the dashboard sidebar: - 🟢 **Green** — events are flowing - 🟡 **Amber** — needs attention (installed, but no recent events) - ⚪ **Gray** — not installed yet Click the badge to open the **SDK Doctor** panel. #### What it shows ##### Status The Doctor reports one of three overall states: | Status | Meaning | |--------|---------| | **Not installed** | No install has ever been recorded for this workspace. | | **Installed** | Installs are recorded, but no in-app events in the last 7 days. | | **Events flowing** | At least one in-app event in the last 7 days — the SDK is fully wired up. | Alongside the status you'll see the **last install** time, the **last event** time, and the **event count** over the last 24 hours. ##### Detected SDKs and versions For every SDK reporting to your workspace, the Doctor lists its **name** (e.g. `react-native`, `expo`), the **version** it's sending from, and a version badge: - **Current** — up to date - **Recent** — slightly behind, still fine - **Outdated** — upgrade recommended - **Unavailable** — version couldn't be determined It also flags **version fragmentation** — e.g. "react-native is sending from 2 versions: 1.3.0, 1.4.0" — which is normal during a rollout but worth watching, since newer analytics features (screen flow, session-scoped attribution) only arrive once users update. ##### Scan events The Doctor caches health for 24 hours. Use **Scan events** to force a fresh check after you've just integrated or shipped an update. #### Health data shape The SDK Doctor is a dashboard feature; its data is served to the dashboard over a session-authenticated endpoint (not part of the API-key surface — see [Authentication](https://docs.linkforty.com/api/authentication/#what-api-keys-can-access)). The health payload looks like this: ```json { "status": "events_flowing", "lastInstallAt": "2026-06-05T14:23:12Z", "lastEventAt": "2026-06-07T09:01:44Z", "eventCount24h": 412, "sdks": [ { "name": "react-native", "version": "1.4.0", "versionStatus": "current", "lastSeenAt": "2026-06-07T09:01:44Z", "eventCount": 412 } ] } ``` Use **Scan events** in the panel to bypass the 24-hour cache and recompute on demand. #### Interpreting the result **"Not installed" but you've shipped the SDK** - The SDK may be initialized but never reaching the network. Enable [`debug`](https://docs.linkforty.com/sdks/react-native/#initconfig) and check the logs for init/install errors. - Test on a **real device** — fingerprint attribution is unreliable in simulators. **"Installed" but stuck (no events)** - Installs are landing, but your app isn't calling [`trackEvent()`](https://docs.linkforty.com/sdks/react-native/#trackeventname-properties) / [`trackRevenue()`](https://docs.linkforty.com/sdks/react-native/#trackrevenueamount-currency-properties). Add event tracking for your key actions, or enable [`autoTrackNavigation`](https://docs.linkforty.com/sdks/react-native/#automatic-screen-tracking) for automatic `screen_view` events. **Version fragmentation** - Expected during a staged rollout. If it persists, prompt users to update so everyone benefits from the latest attribution and analytics features. #### Related - [SDK Integration](https://docs.linkforty.com/guides/attribution/sdk-integration/) — install and initialize - [React Native SDK](https://docs.linkforty.com/sdks/react-native/) · [Expo SDK](https://docs.linkforty.com/sdks/expo/) - [App Analytics](https://docs.linkforty.com/guides/analytics/app-analytics/) — what the SDK's data powers - [Events & Conversions](https://docs.linkforty.com/guides/analytics/events/) — in-app event tracking --- # Analytics ## Analytics Dashboard Source: https://docs.linkforty.com/guides/analytics/dashboard/ Understand your link performance with comprehensive click tracking and analytics. #### Overview LinkForty's analytics dashboard provides real-time insights into: - **Click metrics** - Total and unique clicks - **Geographic data** - Country, city, region distribution - **Device breakdown** - Mobile, desktop, tablet - **Platform analysis** - iOS, Android, Windows - **Traffic sources** - UTM tracking and referrers - **Time patterns** - Clicks by hour and date - **Top performers** - Best-performing links **Tip: In-App Events** Looking for event tracking and conversion analytics? See the [Events & Conversions](https://docs.linkforty.com/guides/analytics/events/) guide. #### Dashboard Views ##### Organization Overview View aggregated analytics across all links in your organization. **Access:** Dashboard home page or **Analytics** → **Overview** **API Endpoint:** ```bash curl "https://api.linkforty.com/api/analytics/overview?days=30" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Key Metrics:** - Total Clicks - Unique Visitors - Top Performing Links - Geographic Distribution - Device & Platform Breakdown ##### Link-Specific Analytics Deep-dive into individual link performance. **Access:** Click any link → **Analytics** tab **API Endpoint:** ```bash curl "https://api.linkforty.com/api/analytics/links/{linkId}?days=30" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **See:** [Link Analytics API Reference](https://docs.linkforty.com/api/endpoints/analytics/link-analytics/) ##### Project Analytics Aggregate analytics for all links in a project. **Access:** Projects → Select project → **Analytics** tab **API Endpoint:** ```bash curl "https://api.linkforty.com/api/analytics/projects/{projectId}?days=30" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **See:** [Projects Guide](https://docs.linkforty.com/guides/team/projects/#project-analytics) #### Time Filtering All analytics views support custom time ranges. **Available Ranges:** - Last 24 hours - Last 7 days - Last 30 days (default) - Last 90 days - Last 365 days - Custom date range **API Parameter:** ```bash ### Last 7 days curl "https://api.linkforty.com/api/analytics/overview?days=7" ### Last 90 days curl "https://api.linkforty.com/api/analytics/overview?days=90" ``` #### Analytics Metrics ##### Click Metrics **Total Clicks:** Total number of times your links were clicked **Unique Clicks:** Number of unique visitors (deduplicated by IP address) **Click-Through Rate (CTR):** ``` CTR = (Unique Clicks / Total Clicks) × 100 ``` **Example:** - Total Clicks: 1,248 - Unique Clicks: 892 - CTR: 71.5% This indicates 71.5% of clicks came from unique visitors. ##### Geographic Analytics **By Country:** ```json { "countryCode": "US", "country": "United States", "clicks": 523 } ``` **By City:** ```json { "city": "New York", "countryCode": "US", "region": "NY", "clicks": 142 } ``` **By Region/State:** ```json { "region": "California", "countryCode": "US", "clicks": 256 } ``` **By Timezone:** ```json { "timezone": "America/New_York", "clicks": 312 } ``` ##### Device & Platform Analytics **Device Type:** - **Mobile** - Smartphones - **Desktop** - Laptop and desktop computers - **Tablet** - iPads and Android tablets **Platform:** - **iOS** - iPhone, iPad - **Android** - Android phones and tablets - **Windows** - Windows PCs - **macOS** - Mac computers - **Linux** - Linux systems **Browser:** - Chrome, Safari, Firefox, Edge, etc. **Example:** ```json { "clicksByDevice": [ { "device": "mobile", "clicks": 742 }, { "device": "desktop", "clicks": 398 }, { "device": "tablet", "clicks": 108 } ], "clicksByPlatform": [ { "platform": "iOS", "clicks": 623 }, { "platform": "Android", "clicks": 425 }, { "platform": "Windows", "clicks": 200 } ] } ``` ##### Traffic Source Analytics **UTM Parameters:** Track campaign performance with UTM parameters. ```json { "clicksByUtmSource": [ { "source": "instagram", "clicks": 523 }, { "source": "email", "clicks": 312 }, { "source": "Direct", "clicks": 187 } ], "clicksByUtmMedium": [ { "medium": "social", "clicks": 642 }, { "medium": "email", "clicks": 312 }, { "medium": "None", "clicks": 187 } ], "clicksByUtmCampaign": [ { "campaign": "spring-sale", "clicks": 823 }, { "campaign": "product-launch", "clicks": 425 } ] } ``` **Referrer Analysis:** See where traffic is coming from. ```json { "clicksByReferrer": [ { "source": "Instagram", "clicks": 523 }, { "source": "Facebook", "clicks": 312 }, { "source": "Direct", "clicks": 187 }, { "source": "Google", "clicks": 142 } ] } ``` ##### Time-Based Analytics **Clicks by Date:** ```json { "clicksByDate": [ { "date": "2024-03-15", "clicks": 245 }, { "date": "2024-03-14", "clicks": 198 }, { "date": "2024-03-13", "clicks": 223 } ] } ``` **Clicks by Hour:** ```json { "clicksByHour": [ { "hour": 14, "clicks": 145 }, // 2 PM { "hour": 15, "clicks": 132 }, // 3 PM { "hour": 10, "clicks": 118 } // 10 AM ] } ``` Use this to identify peak traffic times. #### Top Performing Links See which links drive the most traffic. ```json { "topLinks": [ { "id": "link_abc123", "shortCode": "spring24", "title": "Spring Sale 2024", "originalUrl": "https://shop.example.com/sale", "totalClicks": 1248, "uniqueClicks": 892 }, { "id": "link_def456", "shortCode": "newprod", "title": "Product Launch", "originalUrl": "https://shop.example.com/new", "totalClicks": 823, "uniqueClicks": 612 } ] } ``` #### Recent Activity View real-time click stream. ```bash curl "https://api.linkforty.com/api/analytics/recent?limit=20" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Response:** ```json [ { "id": "click_xyz789", "linkId": "link_abc123", "shortCode": "spring24", "linkTitle": "Spring Sale 2024", "clickedAt": "2024-03-15T14:23:12Z", "deviceType": "mobile", "platform": "iOS", "countryCode": "US", "city": "New York", "utmSource": "instagram", "utmMedium": "social", "utmCampaign": "spring-sale" } ] ``` #### Data Export Export raw analytics data for external analysis. ##### Via Dashboard 1. Go to **Analytics** → **Overview** 2. Click **"Export"** button 3. Select time range 4. Choose format (CSV or JSON) 5. Download file ##### Via API ```bash curl "https://api.linkforty.com/api/analytics/export?startDate=2024-03-01&endDate=2024-03-31&format=csv&limit=1000" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Query Parameters:** - `startDate` - Start date (YYYY-MM-DD) - `endDate` - End date (YYYY-MM-DD) - `linkId` - Filter by specific link (optional) - `format` - `json` or `csv` (default: `json`) - `limit` - Max records (default: 1000, max: 10000) - `offset` - Pagination offset **Response (JSON):** ```json { "data": [ { "id": "click_1", "linkId": "link_abc123", "shortCode": "spring24", "clickedAt": "2024-03-15T14:23:12Z", "ipAddress": "192.168.1.1", "deviceType": "mobile", "platform": "iOS", "browser": "Safari", "countryCode": "US", "city": "New York", "utmSource": "instagram", "utmCampaign": "spring-sale" } ], "pagination": { "total": 5432, "limit": 1000, "offset": 0, "hasMore": true } } ``` **Response (CSV):** ```csv id,linkId,shortCode,clickedAt,deviceType,platform,countryCode,city,utmSource,utmCampaign click_1,link_abc123,spring24,2024-03-15T14:23:12Z,mobile,iOS,US,New York,instagram,spring-sale click_2,link_abc123,spring24,2024-03-15T14:25:03Z,desktop,Windows,CA,Toronto,email,spring-sale ``` **Requires:** Member, Admin, or Owner role #### TypeScript Examples ##### 1. Display Dashboard Overview ```typescript interface AnalyticsOverview { totalClicks: number; uniqueClicks: number; clicksByDate: Array<{ date: string; clicks: number }>; clicksByCountry: Array<{ countryCode: string; country: string; clicks: number }>; clicksByDevice: Array<{ device: string; clicks: number }>; topLinks: Array<{ id: string; shortCode: string; title: string; totalClicks: number; uniqueClicks: number; }>; } async function getDashboardOverview(days: number = 30): Promise { const response = await fetch( `https://api.linkforty.com/api/analytics/overview?days=${days}`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ); return response.json(); } // Usage const overview = await getDashboardOverview(7); console.log(`Last 7 Days Overview`); console.log(`Total Clicks: ${overview.totalClicks.toLocaleString()}`); console.log(`Unique Visitors: ${overview.uniqueClicks.toLocaleString()}`); console.log(`\nTop Link: ${overview.topLinks[0].title}`); console.log(` Clicks: ${overview.topLinks[0].totalClicks.toLocaleString()}`); console.log(`\nTop Country: ${overview.clicksByCountry[0].country}`); console.log(` Clicks: ${overview.clicksByCountry[0].clicks.toLocaleString()}`); ``` ##### 2. Calculate Growth Rate ```typescript async function calculateGrowthRate() { const thisWeek = await fetch( 'https://api.linkforty.com/api/analytics/overview?days=7', { headers: { 'Authorization': `Bearer ${API_KEY}` } } ).then(r => r.json()); const lastWeek = await fetch( 'https://api.linkforty.com/api/analytics/overview?days=14', { headers: { 'Authorization': `Bearer ${API_KEY}` } } ).then(r => r.json()); const thisWeekClicks = thisWeek.totalClicks; const lastWeekClicks = lastWeek.totalClicks - thisWeekClicks; const growth = ((thisWeekClicks - lastWeekClicks) / lastWeekClicks) * 100; return { thisWeek: thisWeekClicks, lastWeek: lastWeekClicks, growth: growth.toFixed(1) + '%', isGrowing: growth > 0, trend: growth > 0 ? 'Growing' : 'Declining', }; } // Usage const growth = await calculateGrowthRate(); console.log(`This week: ${growth.thisWeek} clicks`); console.log(`Last week: ${growth.lastWeek} clicks`); console.log(`Growth: ${growth.growth} ${growth.trend}`); ``` ##### 3. Export and Analyze Data ```typescript async function exportAndAnalyze(startDate: string, endDate: string) { const response = await fetch( `https://api.linkforty.com/api/analytics/export?startDate=${startDate}&endDate=${endDate}&format=json&limit=10000`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ); const { data, pagination } = await response.json(); // Analyze mobile vs desktop const mobile = data.filter(click => click.deviceType === 'mobile').length; const desktop = data.filter(click => click.deviceType === 'desktop').length; const mobilePercentage = ((mobile / data.length) * 100).toFixed(1); // Find peak hour const hourCounts = data.reduce((acc, click) => { const hour = new Date(click.clickedAt).getHours(); acc[hour] = (acc[hour] || 0) + 1; return acc; }, {} as Record); const peakHour = Object.entries(hourCounts) .sort((a, b) => b[1] - a[1])[0]; return { totalRecords: data.length, mobilePercentage: mobilePercentage + '%', desktopPercentage: ((desktop / data.length) * 100).toFixed(1) + '%', peakHour: `${peakHour[0]}:00 (${peakHour[1]} clicks)`, hasMore: pagination.hasMore, }; } ``` ##### 4. Track Campaign Performance ```typescript interface CampaignMetrics { campaign: string; clicks: number; uniqueClicks: number; countries: number; mobilePercentage: string; } async function getCampaignMetrics(linkId: string): Promise { const analytics = await fetch( `https://api.linkforty.com/api/analytics/links/${linkId}?days=30`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ).then(r => r.json()); const link = await fetch( `https://api.linkforty.com/api/links/${linkId}`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ).then(r => r.json()); const mobileClicks = analytics.clicksByDevice.find(d => d.device === 'mobile')?.clicks || 0; const mobilePercentage = ((mobileClicks / analytics.totalClicks) * 100).toFixed(1); return { campaign: link.title || link.shortCode, clicks: analytics.totalClicks, uniqueClicks: analytics.uniqueClicks, countries: analytics.clicksByCountry.length, mobilePercentage: mobilePercentage + '%', }; } // Usage const metrics = await getCampaignMetrics('link_abc123'); console.table([metrics]); ``` ##### 5. Generate Weekly Report ```typescript async function generateWeeklyReport() { const overview = await getDashboardOverview(7); const report = { period: 'Last 7 Days', summary: { totalClicks: overview.totalClicks, uniqueVisitors: overview.uniqueClicks, avgClicksPerDay: (overview.totalClicks / 7).toFixed(1), }, geographic: { topCountry: overview.clicksByCountry[0], countriesReached: overview.clicksByCountry.length, }, devices: { mobile: overview.clicksByDevice.find(d => d.device === 'mobile')?.clicks || 0, desktop: overview.clicksByDevice.find(d => d.device === 'desktop')?.clicks || 0, tablet: overview.clicksByDevice.find(d => d.device === 'tablet')?.clicks || 0, }, topPerformers: overview.topLinks.slice(0, 5).map(link => ({ title: link.title, clicks: link.totalClicks, shortCode: link.shortCode, })), }; console.log(JSON.stringify(report, null, 2)); return report; } ``` #### Understanding Your Data ##### What is a "Unique Click"? A unique click is deduplicated by IP address. If the same IP clicks your link 5 times, it counts as: - **Total Clicks:** 5 - **Unique Clicks:** 1 **Note:** This is an approximation. Multiple users behind the same router/proxy will share an IP. ##### Why do some clicks show "Unknown"? **Geographic data:** - VPN users hide their location - Some IPs aren't in geolocation databases - Privacy-focused browsers block location tracking **Device/Platform data:** - Rare or custom user-agents - Browsers that spoof user-agent strings - Bots and automated traffic ##### How accurate is geolocation? **Country:** ~95% accurate **City:** ~70% accurate **Coordinates:** ~80% accurate Accuracy depends on IP geolocation database quality. Mobile users on cellular networks may show as neighboring cities. ##### What counts as a "click"? A click is logged when: 1. User visits your short link 2. Request reaches LinkForty servers 3. User is redirected to destination **Not counted:** - Bots (filtered by user-agent) - Prefetch requests from browsers - Failed requests (404, errors) #### Best Practices ##### 1. Review Analytics Regularly **Recommended Schedule:** - **Daily:** Check recent activity and top links - **Weekly:** Review growth trends and campaign performance - **Monthly:** Export data and analyze patterns ##### 2. Use UTM Parameters Track campaign sources properly: ```bash curl -X POST https://api.linkforty.com/api/links \ -d '{ "originalUrl": "https://shop.com/sale", "utmParameters": { "source": "instagram", "medium": "social", "campaign": "spring-sale", "content": "story-1" } }' ``` **See:** [UTM Parameters Guide](https://docs.linkforty.com/guides/links/utm-parameters/) ##### 3. Set Up Projects Organize links by campaign for better analytics: ``` Project: Spring Sale 2024 ├── Instagram Story Link ├── Email Newsletter Link ├── Facebook Ad Link └── Twitter Post Link ``` **See:** [Projects Guide](https://docs.linkforty.com/guides/team/projects/) ##### 4. Monitor Geographic Trends If you notice unexpected geographic distribution: - Check for bot traffic - Verify campaign targeting - Look for viral sharing patterns ##### 5. Optimize for Peak Hours Use "Clicks by Hour" data to schedule: - Email sends - Social media posts - Ad campaigns #### Troubleshooting ##### "No analytics data" **Possible causes:** - Link hasn't been clicked yet - Time filter too narrow (try 30 days) - Link created very recently **Fix:** 1. Share link to generate clicks 2. Expand time range 3. Wait 1-2 minutes for data processing ##### Analytics seem delayed **Cause:** Analytics update in near real-time (1-2 minute delay) **Fix:** Refresh dashboard after a minute ##### Duplicate clicks showing **Cause:** Same user clicking multiple times **Solution:** Use "Unique Clicks" metric instead of "Total Clicks" ##### Geographic data missing **Cause:** VPNs, proxies, or missing IP data **Expected:** 5-10% of clicks may have unknown location #### Related Guides - [Link Analytics API](https://docs.linkforty.com/api/endpoints/analytics/link-analytics/) - API reference - [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) - Link setup - [UTM Parameters](https://docs.linkforty.com/guides/links/utm-parameters/) - Campaign tracking - [Projects](https://docs.linkforty.com/guides/team/projects/) - Organizing links #### Next Steps 1. Review your current analytics 2. Set up UTM tracking for campaigns 3. Organize links into projects 4. Export data for external analysis 5. Schedule regular analytics reviews --- ## Campaigns Source: https://docs.linkforty.com/guides/analytics/campaigns/ The **Campaigns** tab answers one question: how is this campaign doing, counting everything it touched — the clicks on your short links *and* the sessions on your site. Find it under **Analytics → Campaigns**. #### The Two Surfaces This is the thing to understand before reading any number on the page. A campaign leaves traces in two different places, and they are not the same trace: | Surface | Where it comes from | What it counts | |---|---|---| | **Clicks** | Short links carrying UTM parameters | Someone clicked your LinkForty link | | **Sessions** | The [web pixel](https://docs.linkforty.com/sdks/web-pixel/) on your site | Someone browsed your site with those UTMs on the URL | Older campaign reporting showed only the first. That misses an entire ordinary case: a campaign whose UTMs are appended to the landing page URL and never touch a short link at all. Those campaigns had **zero rows** — not low numbers, no presence whatsoever. The Campaigns tab joins both, so a campaign appears if *either* surface saw it. A row with clicks and no sessions means the pixel never saw those visitors. A row with sessions and no clicks means the campaign ran entirely through direct links. **Tip: Which number answers which question** - *"How many people did my link reach?"* → **Clicks** - *"How many people actually landed on my site?"* → **Sessions** They are different populations, not two measurements of one. A click that never becomes a session is a drop-off worth investigating. #### Grouping Switch between **Campaign**, **Source**, and **Medium** with the toggle. This changes which UTM parameter the rows are grouped by — `utm_campaign`, `utm_source`, or `utm_medium`. `utm_term` and `utm_content` are not grouping options here. They reach your destination URL but are not recorded on the click row. #### Reading the Table | Column | Meaning | |---|---| | **Clicks** | Short-link clicks carrying this UTM value, bots excluded | | **Installs** | App installs attributed to those clicks | | **Sessions** | Distinct site sessions the pixel recorded with this UTM value | | **Conversions** | Site events matching your workspace's [conversion goals](https://docs.linkforty.com/guides/analytics/web-analytics/) | | **Conv. rate** | See below — the denominator changes | | **Revenue** | Combined in-app and site revenue, per currency | **The Installs column is hidden** when no campaign in range has any. A web-only workspace would otherwise be reading a permanently empty column. **Conversion rate has two possible denominators.** It uses conversions over sessions when the campaign has sessions, and installs over clicks when it does not. When there is nothing to divide by, it shows an em dash rather than `0%` — "nothing to measure" is not the same as a measured zero. **Revenue is reported per currency**, never summed across them. A campaign earning ¥40,000 and $300 shows both; adding them would be meaningless. Click any row to filter the view to that value. Clicking again, or the clear control, returns to the full list. #### Where Campaign Values Come From A click row records a UTM from either of two places: 1. **The short URL itself** — `https://go.example.com/sale?utm_campaign=spring` 2. **The link's own UTM settings** — configured on the link or its template Values on the URL win per key. A link configured with `utm_source=newsletter` whose URL carries `?utm_source=partner` records `partner`, because the value a sharer put on the URL is more specific than the link's stored configuration. Each key resolves independently, so a link that configures only `utm_source` still records a `utm_campaign` that arrives on the URL. **Note: Blank values never appear as rows** Empty and whitespace-only UTMs are treated as absent on both surfaces. They are excluded before the two surfaces are joined, so the table never shows a blank-labelled row. #### What Is Excluded Some traffic is deliberately absent, and its absence is not a bug: - **Bot clicks.** Classified at ingestion and excluded from every campaign figure. - **SDK-created shared links.** Links your app users generate through the SDK are not campaign traffic; see [Shared Links](https://docs.linkforty.com/guides/links/shared-links/) for their own reporting. - **Untagged traffic.** A link with no UTMs anywhere cannot be grouped and does not appear. The table shows the top 100 values, ordered by combined revenue, then sessions, then clicks. #### Time Range The range selector applies to both surfaces at once. How far back you can look depends on your plan's analytics retention — see the [plan comparison](https://docs.linkforty.com/getting-started/cloud-vs-self-hosted/#feature-comparison-matrix) for the current windows. #### Nothing Showing Up? Campaigns appear once your links carry UTM parameters, or once the site pixel records visits with UTMs on the URL. If the table is empty: - Confirm the links you are running actually carry UTMs — see [UTM Parameters](https://docs.linkforty.com/guides/links/utm-parameters/). - If you expect **sessions** but see none, check that the [web pixel](https://docs.linkforty.com/sdks/web-pixel/) is installed and that its allowed-domains setting includes the site. - If you expect **clicks** but see none, check that the traffic is not being classified as bot traffic, and that the time range covers when the campaign ran. - A `403` on this tab means your role lacks the analytics permissions it requires. Both install and event analytics permissions are needed. #### API The same data is available at `GET /api/analytics/campaigns`, which accepts an API key as well as a session: | Parameter | Values | Default | |---|---|---| | `days` | 1–365 | 30 | | `groupBy` | `campaign`, `source`, `medium`, `param` | `campaign` | | `paramKey` | the parameter name to group by — required when `groupBy=param` | — | | `value` | filter to one value | — | | `projectId` | scope to one project | — | ##### Grouping by a Passthrough Parameter `groupBy=param` groups by one of the [passthrough parameters](https://docs.linkforty.com/guides/attribution/deferred-deep-linking/#url-parameter-passthrough) your links carry, rather than by a UTM value. Name the parameter with `paramKey`: ```bash curl -H "Authorization: Bearer $LINKFORTY_API_KEY" \ "https://api.linkforty.com/api/analytics/campaigns?groupBy=param¶mKey=slug&days=30" ``` That answers "which `slug` performed best" across every link that carried one. Two differences from the UTM groupings: - **Clicks only.** The web pixel records UTM values but not passthrough parameters, so `sessions`, `pageViews`, `conversions` and `webRevenue` are `0` on every row. The dashboard hides those columns in this mode. - **`paramKey` is required.** Omitting it returns `400`, rather than grouping by everything. A `paramKey` no click carried returns an empty row set, not an error. **Caution: Read the per-currency revenue fields** The response carries `revenue`, `webRevenue`, and `totalRevenue` for backwards compatibility, but they add amounts across currencies as though they shared a unit. Use `revenueByCurrency`, `webRevenueByCurrency`, and `totalRevenueByCurrency` instead. #### Related - [UTM Parameters](https://docs.linkforty.com/guides/links/utm-parameters/) — tagging links so they appear here - [UTM Library](https://docs.linkforty.com/guides/links/utm-library/) — keeping values consistent across a team - [Web Analytics](https://docs.linkforty.com/guides/analytics/web-analytics/) — the session surface in detail - [Dashboard](https://docs.linkforty.com/guides/analytics/dashboard/) — workspace-level overview --- ## Events & Conversions Source: https://docs.linkforty.com/guides/analytics/events/ Track and analyze in-app events from your mobile SDK to understand user behavior after they click your links. #### Overview The Events dashboard shows in-app events sent by your mobile SDK via `trackEvent()` and `trackRevenue()`. This gives you visibility into what users do **after** they install your app through a LinkForty link: - **Event volume** - Total events, unique event types, and daily counts - **Revenue tracking** - Aggregate revenue from `trackRevenue()` calls - **Top events** - Most frequent event types at a glance - **Event timeline** - Events over time as an area chart - **Event feed** - Paginated table with event names, properties, and attributed links - **CSV export** - Download raw event data for external analysis **Info: Cloud Only** The Events dashboard is available on **Pro** and **Unlimited** plans. Free tier users see an upgrade prompt. Paid plans include **real-time auto-refresh** — the dashboard polls for new events every 30 seconds so you see data as it arrives. #### How Events Flow 1. Your mobile app calls `LinkForty.trackEvent()` or `LinkForty.trackRevenue()` 2. The SDK sends the event to `POST /api/sdk/v1/event` with the install ID 3. The event is stored in the `in_app_events` table with its name, properties, and timestamp 4. The Events dashboard queries this data scoped to your organization Events are linked to your organization through the install attribution chain: each event has an `installId`, which maps to a link click, which belongs to your organization. #### Dashboard Layout ##### Stat Cards Four summary metrics across the top: | Metric | Description | |--------|-------------| | **Total Events** | All events tracked in the selected time range | | **Unique Events** | Number of distinct event names | | **Events Today** | Events received since midnight (UTC) | | **Revenue** | Sum of `revenue` from `trackRevenue()` calls | ##### Time Range Filter Filter all data by time period: **7 days**, **30 days**, **90 days**, or **1 year**. Changing the time range updates stat cards, charts, and the event table. ##### Event Name Filter Filter by a specific event type (e.g., only show `purchase` events). The dropdown is populated from your actual event data. ##### Charts - **Events Over Time** (left, 2/3 width) - Area chart showing daily event counts - **Top Events** (right, 1/3 width) - Horizontal bar chart of the 10 most frequent event types ##### Event Feed Table A paginated table showing individual events: | Column | Description | |--------|-------------| | **Event Name** | The event type (e.g., `purchase`, `signup`) | | **Properties** | Key-value badges showing the event's custom data | | **Link** | The short code of the attributed link, or "Organic" | | **Timestamp** | Relative time (e.g., "2m ago", "Yesterday") | #### Tracking Events ##### Custom Events Track any named event with optional properties: ```typescript import LinkForty from '@linkforty/mobile-sdk-react-native'; // Track a purchase LinkForty.trackEvent('purchase', { productId: 'sku-123', category: 'electronics', value: 49.99, }); // Track a signup LinkForty.trackEvent('signup', { method: 'google' }); // Track a screen view LinkForty.trackEvent('screen_view', { screen: 'product_detail' }); ``` ##### Revenue Events Use `trackRevenue()` for monetary events. This method ensures a consistent format across all SDKs so the dashboard can aggregate revenue: ```typescript // Track a $29.99 USD purchase LinkForty.trackRevenue(29.99, 'USD', { productId: 'sku-123', orderId: 'order-456', }); ``` Revenue events are stored with: - **Event name:** `revenue` - **Event data:** `{ revenue: 29.99, currency: "USD", ...properties }` The **Revenue** stat card on the dashboard sums the `revenue` field from all events named `revenue` in the selected time range. ##### Event Naming Conventions Use consistent, lowercase event names for clean analytics: | Convention | Example | Avoid | |-----------|---------|-------| | snake_case | `add_to_cart` | `AddToCart`, `add-to-cart` | | Descriptive | `purchase_completed` | `event1`, `pc` | | Namespaced | `onboarding_step_3` | `step3` | #### Exporting Event Data Click **Export CSV** in the toolbar to download raw events. The export respects your current filters (time range and event name). You can also export programmatically via the API: ```bash curl "https://api.linkforty.com/api/analytics/events/export?days=30&format=csv" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` See the [Event Analytics API Reference](https://docs.linkforty.com/api/endpoints/analytics/event-analytics/) for all export options. #### API Access All dashboard data is available via the API for building custom reports or integrations: | Endpoint | Description | |----------|-------------| | `GET /api/analytics/events/overview` | Stat cards and top events | | `GET /api/analytics/events/timeline` | Daily event counts | | `GET /api/analytics/events/list` | Paginated event feed | | `GET /api/analytics/events/names` | Distinct event names with counts | | `GET /api/analytics/events/export` | CSV/JSON export | See the full [Event Analytics API Reference](https://docs.linkforty.com/api/endpoints/analytics/event-analytics/). #### Best Practices 1. **Track meaningful events** - Focus on events that represent user value (purchases, signups, content views) rather than low-level interactions 2. **Include useful properties** - Add context like product IDs, categories, or amounts to make your data actionable 3. **Use `trackRevenue()` for money** - Don't track revenue as a regular event; use the dedicated method so the dashboard can aggregate it 4. **Keep event names consistent** - Standardize on snake_case and use the same name across platforms 5. **Don't over-track** - Sending hundreds of events per session adds noise. Track 5-15 key events per user journey #### Limitations - Events from **organic installs** (no link attribution) are not shown in the dashboard for v1. Only events from installs attributed to a LinkForty link appear. - The **Revenue** stat assumes all SDKs use the standard `trackRevenue()` convention. Manually tracked revenue events with different field names won't be aggregated. --- ## Event Library Source: https://docs.linkforty.com/guides/analytics/event-library/ The Event Library is a shared, org-wide glossary for your event names. Give each event a friendly display name, description, and tags once — then hover any event anywhere in the dashboard to see what it means. **Info: Cloud feature** The Event Library is part of LinkForty Cloud. It sits on top of the events you already track — see [Events & Conversions](https://docs.linkforty.com/guides/analytics/events/) for how events are sent and analyzed. #### Why it matters Event names are written for code, not for people. `iap_purchase_v2`, `onb_step3_done`, `add_to_cart_btn` — perfectly clear to the engineer who shipped them, opaque to everyone else looking at a chart three months later. So the same question gets asked over and over: *what does this event actually mean?* The Event Library answers it once, in the place where people are already looking. It's a metadata layer that sits over your raw event names — define an event a single time, and that definition shows up everywhere the event appears. #### The list fills itself Open the **Event Library** from the sidebar to see every event name your organization has ever sent. There's nothing to register — as events flow in from your apps and your website, they show up here automatically, each with its: - **Last-seen** date - Total **occurrence count** - **Source badge** — whether the event came from an app (Mobile SDK) or the web (Web Tracking Pixel) Both surfaces live in the same place, so mobile and web events are managed side by side. #### What you can define For each event you can set: - **Display name** — the human-readable label shown in place of the raw name. - **Description** — what the event is, when it fires, what counts. - **Tags** — group related events (`onboarding`, `revenue`, `growth`) however your team thinks about them. #### Definitions show up everywhere Once an event has a definition, you don't have to come back to the library to use it. Anywhere an event name appears in the dashboard, it renders through the same label component: - It shows the friendly **display name**. - **Hovering** reveals an info card with the description, tags, and the raw event name (one click to copy). The glossary comes to you, in context, instead of making you go look it up. #### Permissions Definitions are org-scoped and shared across your whole team, so everyone reads from the same source of truth. - **Anyone** can hover to learn what an event means. - **Editing** is reserved for members and above (the `events:manage` permission). Removing an event from the library is non-destructive — if the SDK or pixel sends it again, it simply reappears, unlabeled, ready to be defined. #### Getting started 1. Click **Event Library** in the sidebar. 2. Use the search box to find an event. 3. Click the pencil to give it a display name, description, and tags — or edit it inline from the hover card anywhere it shows up. #### Related - [Events & Conversions](https://docs.linkforty.com/guides/analytics/events/) — how events are sent and analyzed. - [Web Analytics](https://docs.linkforty.com/guides/analytics/web-analytics/) — web-pixel events that also appear in the library. - [UTM Library](https://docs.linkforty.com/guides/links/utm-library/) — the same idea, applied to your UTM values. --- ## App Analytics Source: https://docs.linkforty.com/guides/analytics/app-analytics/ Go beyond raw install counts. The **Installs** tab in **Analytics** breaks down where installs come from, how well they're attributed, whether they stick, and what they're worth — all from the data your [mobile SDK](https://docs.linkforty.com/sdks/react-native/) already sends. #### Overview App Analytics turns installs and in-app events into a funnel you can act on: - **Install breakdowns** — installs by platform, country, link, and campaign - **Retention cohorts** — D1 / D7 / D30 retention curves by install cohort - **Attribution quality** — confidence scores and deferred-deep-link delivery rates - **Down-funnel events** — the *actual* in-app events your installs fire, not pre-baked buckets - **Goals, revenue & LTV** — revenue-bearing events rolled up per install - **Cross-surface** — web and app performance for a single link, side by side **Info: Requires the mobile SDK** This data comes from the [mobile SDKs](https://docs.linkforty.com/sdks/react-native/). Installs and events appear once your app reports them via the SDK. Some signals (screen flow, session-scoped event attribution) require the SDK versions that ship `autoTrackNavigation` and attribution stamps. #### Getting there Open **Analytics** and select the **Installs** tab. The time-range selector scopes every card. Select a specific link to see that link's down-funnel and the cross-surface summary. #### Install breakdowns See where installs originate, sliced by dimension: | Dimension | Use it to… | |-----------|------------| | **Platform** | Compare iOS vs Android volume | | **Country** | Find your strongest geographies | | **Link** | See which links drive the most installs | | **Campaign** | Roll installs up by UTM campaign | The **Total Installs** headline shows the change versus the previous equal-length period. #### Retention cohorts Retention groups installs by the day they happened (the cohort) and measures how many came back on later days: - **D1** — returned the day after install - **D7** — returned a week later - **D30** — returned a month later "Returned" means the install fired at least one in-app event on the offset day. Reading the curve down a cohort shows how a given day's users decay; reading across compares cohorts over time. Use it to tell whether a campaign brings *durable* users or one-and-done installs. #### Attribution quality Not every attribution is equally certain. This card surfaces: - **Confidence score distribution** — how strong the fingerprint match was for each attributed install. Lower-confidence matches (shared/NAT IPs, coarse signals) are flagged so you can weight them appropriately. - **Deferred deep-link delivery** — the share of attributed installs where the deferred deep link was actually retrieved by the app, i.e. the user landed where the link intended. If attribution quality looks low, see [Attribution Windows → Troubleshooting](https://docs.linkforty.com/guides/attribution/attribution-windows/#troubleshooting) and the [SDK health check](https://docs.linkforty.com/guides/attribution/sdk-health/). #### Down-funnel events Rather than forcing your events into fixed "engaged / purchased" buckets, App Analytics shows the **real in-app events** your installs fire, ranked by how many installs reached each one. Select a link to see its down-funnel: 1. **Install stages** — Clicks → Installs → Opened (real install signals) 2. **In-app events** — every event your app sent down-funnel, by installs reached, event count, and revenue This is data-driven: a `level_complete` or `subscribe` event shows up because your app sent it, not because LinkForty guessed at a category. Events are scoped to the attributed link's [event attribution window](https://docs.linkforty.com/guides/attribution/attribution-windows/#event-attribution-window). #### Goals, revenue & LTV Revenue is derived from your real events, not a hardcoded event name. Any event that carries a revenue value — sent via [`trackRevenue()`](https://docs.linkforty.com/sdks/react-native/#trackrevenueamount-currency-properties) — rolls up into: - **Revenue** per link and per cohort - **Paying installs** — installs that produced any revenue - **Lifetime value** — revenue divided across the install base ```typescript // Standardized revenue — counted automatically LinkForty.trackRevenue(29.99, 'USD', { productId: 'sku-123', orderId: 'order-456' }); ``` Use `trackRevenue()` (not a custom event) so amounts aggregate consistently across SDKs. See [Events & Conversions](https://docs.linkforty.com/guides/analytics/events/). #### Cross-surface link view When you select a single link, App Analytics shows a **cross-surface** card — the whole journey for that link across web and app in one place: | Surface | Metrics | |---------|---------| | **Web** | Sessions, page views, conversions, revenue | | **App** | Installs, engaged, paying, revenue, D1, D7 | Plus combined clicks and total revenue across both surfaces. This is the single place to answer "what did this link actually produce, end to end?" The web side is powered by the [web pixel](https://docs.linkforty.com/sdks/web-pixel/); the app side by the [mobile SDK](https://docs.linkforty.com/sdks/react-native/). #### Best practices 1. **Adopt the latest SDK** — screen flow, session attribution, and richer down-funnel data depend on the newer SDK versions. Check the [SDK health page](https://docs.linkforty.com/guides/attribution/sdk-health/). 2. **Use `trackRevenue()` for money** — it's the only way revenue rolls up into goals and LTV. 3. **Watch retention, not just installs** — a cheap install that churns on D1 is worse than an expensive one that sticks. 4. **Weight by attribution confidence** — treat low-confidence attributions as directional. #### Related - [Mobile SDK Integration](https://docs.linkforty.com/guides/attribution/sdk-integration/) — set up the SDK - [Attribution Windows](https://docs.linkforty.com/guides/attribution/attribution-windows/) — install and event windows - [SDK Health Check](https://docs.linkforty.com/guides/attribution/sdk-health/) — verify the SDK is reporting - [Events & Conversions](https://docs.linkforty.com/guides/analytics/events/) — in-app event tracking - [Web Analytics](https://docs.linkforty.com/guides/analytics/web-analytics/) — the web counterpart --- ## Web Analytics Source: https://docs.linkforty.com/guides/analytics/web-analytics/ Understand the web side of your link journeys — sessions, engagement, and conversions captured by the [web pixel](https://docs.linkforty.com/sdks/web-pixel/) and attributed back to the links that drove them. #### Overview The **Web** tab in **Analytics** turns raw pixel events into a full picture of on-site behavior: - **Sessions & visitors** — total sessions, unique visitors (new vs returning), page views - **Engagement** — bounce rate, pages per session, average time on page - **Conversions & revenue** — conversion counts, total revenue, average order value - **Breakdowns** — slice every metric by source, device, country, browser, referrer, and more - **Per-link drill-in** — the web performance of a single link - **Journeys** — top entry and exit pages - **Period comparison** — every headline metric shows the change versus the previous equal-length period **Info: Requires the web pixel** Web Analytics is populated by the [web pixel](https://docs.linkforty.com/sdks/web-pixel/). Install the snippet on your site first; data appears within a minute or two of the first page load. #### Getting there Open **Analytics** and select the **Web** tab. Use the time-range selector (7 / 30 / 90 / 365 days) to scope every card on the page. To see a single link's web data, select the link — the view filters to that link and a cross-surface summary appears (see [App Analytics → Cross-surface](https://docs.linkforty.com/guides/analytics/app-analytics/#cross-surface-link-view)). #### Headline metrics | Metric | Meaning | |--------|---------| | **Sessions** | Distinct visits (a session is per-tab and time-boxed). | | **Unique visitors** | Distinct visitor IDs — only populated when the pixel runs with [`data-visitors="true"`](https://docs.linkforty.com/sdks/web-pixel/#visitor-id-opt-in). | | **Page views** | Total `page_view` events, including SPA route changes. | | **Bounce rate** | Share of sessions where the visitor never scrolled past ~25% or interacted. | | **Pages / session** | Page views divided by sessions. | | **Avg time on page** | Mean engaged time, derived from `engagement` events. | | **Conversions** | Total `conversion` events (declarative, programmatic, and autocapture). | | **Revenue** | Sum of the `revenue` property across conversions. | | **Avg order value** | Revenue divided by revenue-bearing conversions. | Each headline metric is shown alongside its delta versus the previous period of the same length (e.g. the last 30 days versus the 30 days before that). #### Breakdowns The breakdown card lets you slice any metric by a dimension. Switch the dimension to see where sessions, conversions, and revenue come from: | Dimension | Examples | |-----------|----------| | **Source / Medium / Campaign** | UTM values from the landing URL | | **Content** | The `utm_content` value — slice by which on-page placement or CTA drove the visit | | **Referrer** | The referring domain | | **Referrer category** | Search, social, referral, or direct | | **Device** | Desktop, mobile, tablet | | **Platform / Browser** | OS and browser family | | **Country / Region / City** | Coarse geolocation derived at ingestion | Geolocation is derived from the request IP and the raw IP is then discarded — see the pixel's [privacy behavior](https://docs.linkforty.com/sdks/web-pixel/#privacy). #### Per-link drill-in Select a link to scope the entire Web tab to that link's traffic. This answers "how does *this* campaign perform on the web?" — its sessions, engagement, conversions, and revenue, with the same period-over-period deltas. It's the web counterpart to the link's app analytics. #### Journeys The journeys card surfaces the **top entry pages** (where sessions begin) and **top exit pages** (where they end). Use it to find the pages that pull visitors in and the pages where they drop off. #### Goals & revenue Any conversion that carries a numeric `revenue` property feeds the revenue and average-order-value metrics. To record revenue: ```html ``` ```js window.LinkForty.track('checkout_completed', { revenue: 49.0, plan: 'pro' }); ``` The dashboard ranks conversion events by volume and revenue so you can see which on-site actions actually drive value. See [Tracking conversions](https://docs.linkforty.com/sdks/web-pixel/#tracking-conversions) for the full pixel API. #### How web attribution works 1. A visitor clicks a LinkForty link carrying UTM parameters and lands on your site. 2. The pixel reads those UTM parameters and attaches them to every event in the session. 3. At ingestion, LinkForty matches the UTM parameters to the originating link and stamps the event with `attributed_link_id`. 4. In single-page apps, the originating UTM is remembered for the session, so conversions on UTM-less routes still attribute correctly (last-click). See [SPA route tracking](https://docs.linkforty.com/sdks/web-pixel/#spa-route-tracking). Events that can't be matched to a link still appear in aggregate web metrics — they just aren't attributed to a specific link. #### Best practices 1. **Tag your links with UTM parameters** — attribution is UTM-based; untagged traffic can't be tied to a link. See [UTM Parameters](https://docs.linkforty.com/guides/links/utm-parameters/). 2. **Track conversions that represent value** — signups, purchases, demo requests — and put money in a `revenue` property so it rolls up. 3. **Enable visitor IDs only with consent** — new-vs-returning needs `data-visitors="true"`, which is consent-sensitive. 4. **Compare periods** — use the built-in deltas rather than eyeballing raw totals. #### Related - [Web Pixel](https://docs.linkforty.com/sdks/web-pixel/) — install and configure the pixel - [App Analytics](https://docs.linkforty.com/guides/analytics/app-analytics/) — the mobile-install counterpart - [Analytics Dashboard](https://docs.linkforty.com/guides/analytics/dashboard/) — click-level analytics - [Events & Conversions](https://docs.linkforty.com/guides/analytics/events/) — in-app (mobile) events --- ## Data Export Source: https://docs.linkforty.com/guides/analytics/data-export/ Export your analytics data for external analysis, reporting, and integration with other tools. #### Overview LinkForty's data export feature allows you to: - **Export raw click events** with complete metadata - **Download in multiple formats** (JSON, CSV) - **Filter by date range** and specific links - **Paginate large datasets** for efficient processing - **Automate exports** via API for scheduled reports **Use Cases:** - Import into business intelligence tools (Tableau, Power BI) - Custom analytics and machine learning - Compliance and data retention - Backup and archival - Third-party integration #### Export Formats ##### JSON Format Structured data perfect for programmatic processing. **Advantages:** - Preserves data types - Nested structures supported - Easy to parse with modern tools - Includes pagination metadata **Example:** ```json { "data": [ { "id": "click_xyz789", "linkId": "link_abc123", "shortCode": "spring24", "linkTitle": "Spring Sale 2024", "originalUrl": "https://shop.example.com/sale", "clickedAt": "2024-03-15T14:23:12Z", "ipAddress": "192.168.1.1", "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0...)", "deviceType": "mobile", "platform": "iOS", "browser": "Safari", "browserVersion": "17.0", "os": "iOS", "osVersion": "17.0", "countryCode": "US", "countryName": "United States", "region": "California", "city": "San Francisco", "timezone": "America/Los_Angeles", "latitude": 37.7749, "longitude": -122.4194, "utmSource": "instagram", "utmMedium": "social", "utmCampaign": "spring-sale", "utmTerm": null, "utmContent": "story-1", "referrer": "https://www.instagram.com/" } ], "pagination": { "total": 5432, "limit": 1000, "offset": 0, "hasMore": true } } ``` ##### CSV Format Spreadsheet-friendly format for Excel, Google Sheets, and data analysis tools. **Advantages:** - Opens in Excel/Sheets directly - Smaller file size - Easy to share with non-technical users - Compatible with most tools **Example:** ```csv id,linkId,shortCode,clickedAt,deviceType,platform,countryCode,city,utmSource,utmCampaign click_1,link_abc123,spring24,2024-03-15T14:23:12Z,mobile,iOS,US,San Francisco,instagram,spring-sale click_2,link_abc123,spring24,2024-03-15T14:25:03Z,desktop,Windows,CA,Toronto,email,spring-sale click_3,link_def456,newprod,2024-03-15T14:27:18Z,mobile,Android,GB,London,twitter,product-launch ``` #### Exporting Data ##### Via Dashboard 1. Go to **Analytics** → **Overview** 2. Click **"Export Data"** button 3. Configure export: - **Date Range:** Select start and end dates - **Link Filter:** (Optional) Filter by specific link - **Format:** Choose JSON or CSV - **Limit:** Max records per export 4. Click **"Export"** 5. Download file ##### Via API ```bash curl "https://api.linkforty.com/api/analytics/export?startDate=2024-03-01&endDate=2024-03-31&format=csv&limit=1000" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Query Parameters:** | Parameter | Type | Required | Description | Default | |-----------|------|----------|-------------|---------| | `startDate` | string (YYYY-MM-DD) | No | Export start date | 30 days ago | | `endDate` | string (YYYY-MM-DD) | No | Export end date | Today | | `linkId` | string (UUID) | No | Filter by specific link | All links | | `format` | `json` \| `csv` | No | Export format | `json` | | `limit` | number | No | Max records (1-10000) | 1000 | | `offset` | number | No | Pagination offset | 0 | **Requires:** Member, Admin, or Owner role #### Export Fields All exports include the following fields: ##### Click Event Data | Field | Type | Description | |-------|------|-------------| | `id` | string | Unique click event ID | | `linkId` | string | Associated link ID | | `shortCode` | string | Link's short code | | `linkTitle` | string | Link title | | `originalUrl` | string | Destination URL | | `clickedAt` | timestamp | When click occurred (UTC) | ##### User Information | Field | Type | Description | |-------|------|-------------| | `ipAddress` | string | User's IP address | | `userAgent` | string | Full user-agent string | ##### Device & Platform | Field | Type | Description | |-------|------|-------------| | `deviceType` | string | mobile, desktop, or tablet | | `platform` | string | iOS, Android, Windows, macOS, Linux | | `browser` | string | Chrome, Safari, Firefox, Edge, etc. | | `browserVersion` | string | Browser version number | | `os` | string | Operating system | | `osVersion` | string | OS version number | ##### Geographic Data | Field | Type | Description | |-------|------|-------------| | `countryCode` | string | ISO 3166-1 alpha-2 country code | | `countryName` | string | Full country name | | `region` | string | State/province | | `city` | string | City name | | `timezone` | string | IANA timezone identifier | | `latitude` | number | Geographic latitude | | `longitude` | number | Geographic longitude | ##### Campaign Tracking | Field | Type | Description | |-------|------|-------------| | `utmSource` | string | Campaign source | | `utmMedium` | string | Campaign medium | | `utmCampaign` | string | Campaign name | | `utmTerm` | string | Campaign term (optional) | | `utmContent` | string | Campaign content (optional) | | `referrer` | string | HTTP referrer URL | #### Pagination For large datasets, use pagination to retrieve data in chunks. ##### Example: Paginated Export ```typescript async function exportAllData( startDate: string, endDate: string ): Promise { const allData = []; let offset = 0; const limit = 1000; let hasMore = true; while (hasMore) { const response = await fetch( `https://api.linkforty.com/api/analytics/export?` + `startDate=${startDate}&endDate=${endDate}&` + `format=json&limit=${limit}&offset=${offset}`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ); const result = await response.json(); allData.push(...result.data); hasMore = result.pagination.hasMore; offset += limit; console.log(`Fetched ${allData.length} of ${result.pagination.total} records`); // Rate limit: wait 100ms between requests await new Promise(resolve => setTimeout(resolve, 100)); } return allData; } // Usage const allClicks = await exportAllData('2024-03-01', '2024-03-31'); console.log(`Exported ${allClicks.length} total clicks`); ``` #### Common Export Scenarios ##### 1. Monthly Report Export ```typescript async function exportMonthlyReport(year: number, month: number) { // Calculate date range const startDate = new Date(year, month - 1, 1) .toISOString() .split('T')[0]; const endDate = new Date(year, month, 0) .toISOString() .split('T')[0]; // Export as CSV const response = await fetch( `https://api.linkforty.com/api/analytics/export?` + `startDate=${startDate}&endDate=${endDate}&format=csv&limit=10000`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ); const csv = await response.text(); // Save to file const filename = `analytics-${year}-${month.toString().padStart(2, '0')}.csv`; await fs.writeFile(filename, csv); console.log(`Exported to ${filename}`); } // Export March 2024 await exportMonthlyReport(2024, 3); ``` ##### 2. Link-Specific Export ```typescript async function exportLinkData(linkId: string, days: number = 30) { const endDate = new Date().toISOString().split('T')[0]; const startDate = new Date(Date.now() - days * 24 * 60 * 60 * 1000) .toISOString() .split('T')[0]; const response = await fetch( `https://api.linkforty.com/api/analytics/export?` + `startDate=${startDate}&endDate=${endDate}&` + `linkId=${linkId}&format=json&limit=10000`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ); const result = await response.json(); return { link: linkId, clicks: result.data, total: result.pagination.total, }; } // Export specific link const linkData = await exportLinkData('link_abc123', 30); console.log(`Exported ${linkData.total} clicks for link ${linkData.link}`); ``` ##### 3. Automated Daily Backup ```typescript async function dailyBackup() { const today = new Date().toISOString().split('T')[0]; const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000) .toISOString() .split('T')[0]; const response = await fetch( `https://api.linkforty.com/api/analytics/export?` + `startDate=${yesterday}&endDate=${today}&format=json&limit=10000`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ); const result = await response.json(); // Save to backup storage const filename = `backup-${yesterday}.json`; await fs.writeFile(filename, JSON.stringify(result, null, 2)); console.log(`Backed up ${result.pagination.total} clicks to ${filename}`); } // Run daily via cron // 0 1 * * * node daily-backup.js ``` ##### 4. Campaign Performance Analysis ```typescript async function analyzeCampaignPerformance( campaign: string, startDate: string, endDate: string ) { const allData = await exportAllData(startDate, endDate); // Filter by campaign const campaignClicks = allData.filter( click => click.utmCampaign === campaign ); // Analyze const analysis = { campaign, totalClicks: campaignClicks.length, uniqueIPs: new Set(campaignClicks.map(c => c.ipAddress)).size, topCountries: getTopN(campaignClicks, 'countryName', 5), topCities: getTopN(campaignClicks, 'city', 5), deviceBreakdown: getBreakdown(campaignClicks, 'deviceType'), platformBreakdown: getBreakdown(campaignClicks, 'platform'), hourlyDistribution: getHourlyDistribution(campaignClicks), }; return analysis; } function getTopN(data: any[], field: string, n: number) { const counts = data.reduce((acc, item) => { const value = item[field] || 'Unknown'; acc[value] = (acc[value] || 0) + 1; return acc; }, {} as Record); return Object.entries(counts) .sort((a, b) => b[1] - a[1]) .slice(0, n) .map(([name, clicks]) => ({ name, clicks })); } function getBreakdown(data: any[], field: string) { const counts = data.reduce((acc, item) => { const value = item[field] || 'Unknown'; acc[value] = (acc[value] || 0) + 1; return acc; }, {} as Record); return Object.entries(counts).map(([type, clicks]) => ({ type, clicks })); } function getHourlyDistribution(data: any[]) { const hourCounts = data.reduce((acc, click) => { const hour = new Date(click.clickedAt).getHours(); acc[hour] = (acc[hour] || 0) + 1; return acc; }, {} as Record); return Object.entries(hourCounts) .map(([hour, clicks]) => ({ hour: parseInt(hour), clicks })) .sort((a, b) => a.hour - b.hour); } // Usage const analysis = await analyzeCampaignPerformance( 'spring-sale', '2024-03-01', '2024-03-31' ); console.log(JSON.stringify(analysis, null, 2)); ``` ##### 5. Export to Google Sheets ```typescript import { google } from 'googleapis'; async function exportToGoogleSheets( startDate: string, endDate: string, spreadsheetId: string ) { // Fetch data const response = await fetch( `https://api.linkforty.com/api/analytics/export?` + `startDate=${startDate}&endDate=${endDate}&format=json&limit=10000`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ); const result = await response.json(); // Prepare for Sheets const headers = [ 'Date', 'Short Code', 'Title', 'Country', 'City', 'Device', 'Platform', 'UTM Source', 'UTM Campaign', ]; const rows = result.data.map(click => [ new Date(click.clickedAt).toLocaleString(), click.shortCode, click.linkTitle, click.countryName, click.city, click.deviceType, click.platform, click.utmSource || 'Direct', click.utmCampaign || 'None', ]); // Authenticate with Google const auth = new google.auth.GoogleAuth({ keyFile: 'credentials.json', scopes: ['https://www.googleapis.com/auth/spreadsheets'], }); const sheets = google.sheets({ version: 'v4', auth }); // Write to sheet await sheets.spreadsheets.values.update({ spreadsheetId, range: 'Analytics!A1', valueInputOption: 'RAW', requestBody: { values: [headers, ...rows], }, }); console.log(`Exported ${rows.length} rows to Google Sheets`); } ``` #### Data Processing Examples ##### Convert CSV to JSON ```typescript import * as fs from 'fs/promises'; import { parse } from 'csv-parse/sync'; async function csvToJson(csvFilePath: string) { const csvContent = await fs.readFile(csvFilePath, 'utf-8'); const records = parse(csvContent, { columns: true, skip_empty_lines: true, }); return records; } // Usage const data = await csvToJson('analytics-2024-03.csv'); console.log(`Parsed ${data.length} records`); ``` ##### Filter and Transform ```typescript async function filterAndTransform( startDate: string, endDate: string, filters: { country?: string; device?: string; campaign?: string; } ) { const allData = await exportAllData(startDate, endDate); let filtered = allData; if (filters.country) { filtered = filtered.filter(c => c.countryCode === filters.country); } if (filters.device) { filtered = filtered.filter(c => c.deviceType === filters.device); } if (filters.campaign) { filtered = filtered.filter(c => c.utmCampaign === filters.campaign); } // Transform for external tool return filtered.map(click => ({ timestamp: click.clickedAt, link: click.shortCode, location: `${click.city}, ${click.countryName}`, device: `${click.deviceType} - ${click.platform}`, source: click.utmSource || 'Direct', campaign: click.utmCampaign || 'None', })); } // Export US mobile clicks from Instagram const filtered = await filterAndTransform( '2024-03-01', '2024-03-31', { country: 'US', device: 'mobile', campaign: 'spring-sale', } ); console.log(`Filtered to ${filtered.length} matching clicks`); ``` ##### Generate Summary Statistics ```typescript async function generateSummaryStats( startDate: string, endDate: string ) { const allData = await exportAllData(startDate, endDate); return { overview: { totalClicks: allData.length, uniqueIPs: new Set(allData.map(c => c.ipAddress)).size, dateRange: { startDate, endDate }, }, geographic: { countries: new Set(allData.map(c => c.countryCode)).size, topCountry: getTopN(allData, 'countryName', 1)[0], topCity: getTopN(allData, 'city', 1)[0], }, devices: { mobile: allData.filter(c => c.deviceType === 'mobile').length, desktop: allData.filter(c => c.deviceType === 'desktop').length, tablet: allData.filter(c => c.deviceType === 'tablet').length, mobilePercentage: ( (allData.filter(c => c.deviceType === 'mobile').length / allData.length) * 100 ).toFixed(1) + '%', }, platforms: getBreakdown(allData, 'platform'), campaigns: getBreakdown(allData, 'utmCampaign'), }; } // Usage const stats = await generateSummaryStats('2024-03-01', '2024-03-31'); console.log(JSON.stringify(stats, null, 2)); ``` #### Export Limits ##### By Plan | Plan | Max Records per Export | Max Exports per Day | |------|------------------------|---------------------| | **Free** | 1,000 | 10 | | **Pro** | 10,000 | 100 | | **Business** | 100,000 | Unlimited | | **Unlimited** | Unlimited | Unlimited | ##### Rate Limits - **API Exports:** Subject to standard API rate limits (see [Rate Limits](https://docs.linkforty.com/api/rate-limits/)) - **Concurrent Exports:** 1 active export per organization - **Recommended Delay:** 100ms between paginated requests #### Best Practices ##### 1. Use Appropriate Date Ranges **Good:** ```typescript // Export one month at a time await exportMonthlyReport(2024, 3); ``` **Bad:** ```typescript // Trying to export entire year at once await exportAllData('2024-01-01', '2024-12-31'); // May timeout ``` ##### 2. Paginate Large Exports **Good:** ```typescript // Fetch in chunks of 1000 let offset = 0; while (hasMore) { const chunk = await fetchChunk(offset, 1000); offset += 1000; } ``` **Bad:** ```typescript // Trying to fetch 100k records at once await fetch('...&limit=100000'); // Will fail ``` ##### 3. Cache Exported Data **Good:** ```typescript // Check if already exported today const cacheFile = `export-${today}.json`; if (await fileExists(cacheFile)) { return await readFromCache(cacheFile); } ``` ##### 4. Schedule Off-Peak Exports ```typescript // Run exports during low-traffic hours // 2 AM daily via cron // 0 2 * * * node export-script.js ``` ##### 5. Validate Exported Data ```typescript async function validateExport(data: any[]) { // Check for required fields const requiredFields = ['id', 'linkId', 'clickedAt']; for (const record of data) { for (const field of requiredFields) { if (!record[field]) { throw new Error(`Missing required field: ${field}`); } } } console.log(`Validated ${data.length} records`); } ``` #### Troubleshooting ##### "Export limit exceeded" **Cause:** Requested more records than plan allows **Fix:** 1. Reduce `limit` parameter 2. Use pagination to fetch in chunks 3. Upgrade plan for higher limits ##### "No data in export" **Causes:** - No clicks in date range - Link filter excludes all data - Date range formatted incorrectly **Fix:** 1. Verify date format: `YYYY-MM-DD` 2. Check that links have clicks in range 3. Remove link filter to export all data ##### Export times out **Cause:** Requesting too much data at once **Fix:** 1. Reduce date range (try 1 month at a time) 2. Use pagination with smaller `limit` (1000-2000) 3. Export specific links instead of all ##### CSV encoding issues **Cause:** Special characters in data **Fix:** ```typescript // Ensure UTF-8 encoding when saving await fs.writeFile(filename, csv, { encoding: 'utf-8' }); ``` #### Related Guides - [Analytics Dashboard](https://docs.linkforty.com/guides/analytics/dashboard/) - Understanding your analytics - [Link Analytics API](https://docs.linkforty.com/api/endpoints/analytics/link-analytics/) - API reference - [UTM Parameters](https://docs.linkforty.com/guides/links/utm-parameters/) - Campaign tracking setup #### Next Steps 1. Export a sample dataset 2. Set up automated daily/weekly exports 3. Integrate with your BI tools 4. Create custom analysis scripts 5. Schedule regular data backups --- # Team & Organization ## Organizations Source: https://docs.linkforty.com/guides/team/organizations/ **Info: Cloud Feature** Organizations are a Cloud-only feature. Self-hosted Core deployments are single-tenant and do not include multi-user organizations. See [Cloud vs Self-Hosted](https://docs.linkforty.com/getting-started/cloud-vs-self-hosted/) for details. Organizations let teams collaborate on link management, with separate workspaces, billing, and member access control. Organizations are managed in the LinkForty dashboard. (API keys are scoped to links, analytics, templates, and projects — see [Authentication](https://docs.linkforty.com/api/authentication/#what-api-keys-can-access).) #### Overview An **organization** is a workspace where teams can: - Create and manage links together - Share analytics and insights - Control access with roles and permissions - Manage billing and subscriptions centrally - Organize work with projects **Key Concepts:** - **Personal Workspace** - Your individual account (always exists) - **Organization** - Team workspace with multiple members - **Owner** - Creates and owns the organization (billing, deletion) - **Members** - Team members with assigned roles #### Creating an Organization 1. Click your profile menu (top right) 2. Select **"Create Organization"** 3. Enter an organization name 4. Click **"Create"** You're now the organization owner and can invite team members. The organization's URL slug is generated automatically with a random suffix for uniqueness. #### Personal Workspace vs Organization | Feature | Personal Workspace | Organization | |---------|-------------------|--------------| | **Members** | Just you | Multiple team members | | **Billing** | Personal payment | Shared team billing | | **Roles** | You're the owner | Owner, Admin, Member, Viewer | | **Permissions** | Full access | Role-based access control | | **Invitations** | Not applicable | Email invitations | | **Projects** | Personal projects | Team projects | | **Switching** | N/A | Switch between orgs | #### Organization Structure ``` Organization: Acme Marketing Team ├── Owner: john@acme.com ├── Members: │ ├── Admin: sarah@acme.com │ ├── Member: mike@acme.com │ └── Viewer: emma@acme.com ├── Links: 1,248 links ├── Projects: │ ├── Instagram Campaigns │ ├── Email Marketing │ └── Product Launches └── Billing: Pro Plan ``` #### Switching Between Organizations All your work in LinkForty is scoped to the **active organization**. When you switch organizations, the dashboard shows that org's links, analytics, projects, team members, and settings. 1. Click your profile menu 2. Select **"Switch Organization"** 3. Choose an organization from the list — the dashboard updates to show its data #### Usage and Limits Each organization has limits based on its subscription tier. View current usage against your limits in **Settings → Plan & Billing**. | Tier | Links | Members | Custom Domains | |------|-------|---------|----------------| | **Free** | 1,000 | 3 | 1 | | **Pro** | 25,000 | 10 | 5 | | **Business** | 100,000 | 25 | 15 | | **Unlimited** | Unlimited | Unlimited | Unlimited | For current plan pricing, see the [pricing page](https://linkforty.com/pricing). #### Updating an Organization Rename your organization or adjust its settings (such as the default attribution window or whether members may invite others) in **Settings → Organization**. Renaming and settings changes require the **Admin** or **Owner** role. #### Deleting an Organization **Only the organization owner can delete an organization.** Deletion is permanent and removes all links, analytics data, projects, team members, custom domains, and billing. 1. Go to **Settings → Organization** 2. Scroll to **"Danger Zone"** 3. Click **"Delete Organization"** 4. Type the organization name to confirm 5. Click **"Delete Permanently"** #### Common Workflows ##### Agency Managing Multiple Clients Create a separate organization for each client for isolated data, billing, and team members: ``` Personal Workspace (your own projects) + Client: Nike + Client: Adidas ``` **Benefits:** separate billing per client, isolated analytics, client-specific members, and easy context switching. ##### Company with Multiple Teams Use one organization with multiple projects rather than separate organizations: ``` Organization: Acme Corp ├── Project: Marketing Team ├── Project: Product Team └── Project: Sales Team ``` **Benefits:** shared billing, cross-team visibility, and centralized management. #### Best Practices ##### Clear Naming Convention **Good:** "Acme Marketing Team", "Client: Nike - Spring Campaign", "Personal - Side Projects" **Avoid:** "Org1", "Test", "New Organization" ##### Monitor Usage Check **Settings → Billing** periodically. As you approach a plan limit (links, members, domains), plan an upgrade before you hit it. ##### Organize with Projects, Not Extra Organizations Use [projects](https://docs.linkforty.com/guides/team/projects/) for different campaigns within one organization rather than spinning up separate organizations: **Good:** ``` Organization: Acme Marketing ├── Project: Spring Sale 2024 ├── Project: Product Launch └── Project: Email Campaigns ``` **Avoid:** a separate organization per campaign. #### Troubleshooting ##### Can't see an organization's links - Confirm the correct organization is active (profile menu → Switch Organization) - Verify you're still a member of that organization ##### Can't create links (limit reached) - Check usage in **Settings → Billing** - Delete unused links, or upgrade your plan ##### Can't invite members - Confirm you have the **Admin** or **Owner** role - Check member usage against your plan limit; upgrade if needed ##### Lost access to an organization - Your role may have changed, or you were removed from the organization. Contact the organization owner or an admin. #### Security ##### Access Control - **Role-based permissions** — see [Roles & Permissions](https://docs.linkforty.com/guides/team/roles-permissions/) - **2FA enforcement** — require for all members (Unlimited) - **IP whitelisting** — restrict access by IP (Unlimited) ##### Data Isolation - Data is scoped to each organization — no cross-organization access - Deleted organizations are fully removed #### Related Guides - [Roles & Permissions](https://docs.linkforty.com/guides/team/roles-permissions/) - Understanding access control - [Inviting Members](https://docs.linkforty.com/guides/team/inviting-members/) - Add team members - [Projects](https://docs.linkforty.com/guides/team/projects/) - Organize links within organizations #### Next Steps 1. Create your first organization 2. Invite team members 3. Set up projects for the organization 4. Configure organization settings --- ## Roles & Permissions Source: https://docs.linkforty.com/guides/team/roles-permissions/ **Info: Cloud Feature** Roles and permissions are a Cloud-only feature. Self-hosted Core deployments are single-tenant and do not include role-based access control. See [Cloud vs Self-Hosted](https://docs.linkforty.com/getting-started/cloud-vs-self-hosted/) for details. Control what team members can do in your organization with role-based access control. Roles and members are managed in the LinkForty dashboard. #### Overview LinkForty uses **role-based access control (RBAC)** to manage permissions. Each organization member has a role that determines their capabilities. **Available Roles:** - **Owner** - Full control (1 per organization) - **Admin** - Manage settings and members - **Member** - Create and manage links - **Viewer** - Read-only access #### Role Comparison | Permission | Owner | Admin | Member | Viewer | |-----------|-------|-------|--------|--------| | **Links** | | View links | Yes | ✅ | Yes | ✅ | | Create links | Yes | ✅ | Yes | No | | Edit links | Yes | ✅ | Yes | No | | Delete links | Yes | ✅ | Yes | No | | Bulk operations | Yes | ✅ | Yes | No | | **Analytics** | | View analytics | Yes | ✅ | Yes | ✅ | | Export data | Yes | ✅ | Yes | No | | **Projects** | | View projects | Yes | ✅ | Yes | ✅ | | Create projects | Yes | ✅ | Yes | No | | Edit projects | Yes | ✅ | Yes | No | | Delete projects | Yes | ✅ | No | ❌ | | **Team** | | View members | Yes | ✅ | Yes | ✅ | | Invite members | Yes | ✅ | No | ❌ | | Remove members | Yes | ✅ | No | ❌ | | Change roles | Yes | ✅* | No | ❌ | | **Organization** | | View settings | Yes | ✅ | Yes | ✅ | | Edit settings | Yes | ✅ | No | ❌ | | Manage billing | Yes | No | ❌ | No | | Delete organization | Yes | No | ❌ | No | | **Webhooks** | | View webhooks | Yes | ✅ | Yes | ✅ | | Create webhooks | Yes | ✅ | No | ❌ | | Edit webhooks | Yes | ✅ | No | ❌ | | Delete webhooks | Yes | ✅ | No | ❌ | | **Custom Domains** | | View domains | Yes | ✅ | Yes | ✅ | | Add domains | Yes | ✅ | No | ❌ | | Remove domains | Yes | ✅ | No | ❌ | | **API Keys** | | View API keys | Yes | ✅ | Yes | No | | Create API keys | Yes | ✅ | No | ❌ | | Delete API keys | Yes | ✅ | No | ❌ | \* Admins can't promote members to Owner #### Role Descriptions ##### Owner The organization creator with full control. **Unique Abilities:** - Delete organization - Manage billing and subscriptions - Transfer ownership - Cannot be removed (must transfer ownership first) **Limitations:** - Only 1 owner per organization - Cannot leave without transferring ownership or deleting org **Use Case:** Founder, company owner, billing administrator ##### Admin Trusted team leads who manage the organization. **Can Do:** - Manage all settings - Invite and remove members - Create and manage resources - Change member roles (except Owner) **Cannot Do:** - Delete organization - Manage billing - Promote to Owner **Use Case:** Team leads, managers, senior staff ##### Member Regular team members who work with links. **Can Do:** - Create and manage links - View analytics - Work with projects - Export data **Cannot Do:** - Invite team members - Change settings - Manage billing - Delete organization **Use Case:** Marketing team, content creators, developers ##### Viewer Read-only access for stakeholders. **Can Do:** - View links - View analytics - View projects - View team members **Cannot Do:** - Create or edit anything - Export data - Invite members **Use Case:** Clients, stakeholders, auditors, interns #### Managing Roles ##### Viewing Member Roles Go to **Settings → Team** to see all members with their roles and join dates. ##### Changing Member Roles 1. Go to **Settings → Team** 2. Open the member's role dropdown 3. Select a new role and save **Requires:** Owner or Admin role. **Restrictions:** - Admins cannot promote anyone to Owner - The Owner role can't be reassigned this way — use Transfer Ownership - You can't lower your own role ##### Transferring Ownership **Only the current owner** can transfer ownership: 1. Go to **Settings → Organization** 2. Scroll to **"Transfer Ownership"** 3. Select the new owner (must be an existing member) 4. Click **"Transfer"** and confirm When ownership transfers, the new owner gets the Owner role, the previous owner becomes an Admin, and billing moves to the new owner. This can't be undone except by transferring back. #### Permission Enforcement Permissions are enforced **server-side** on every request — a member can never perform an action their role disallows, regardless of what the UI shows. The dashboard also **hides** actions a member can't take (for example, only Owners see "Delete Organization"; only Owners and Admins see "Invite Member"), so the interface always matches each member's role. #### Common Scenarios ##### 1. Small Team (2-5 people) **Setup:** - 1 Owner (founder) - 1-2 Admins (co-founders, leads) - 1-2 Members (team) **Rationale:** Everyone can work, admins handle team management. ##### 2. Agency (10-25 people) **Setup:** - 1 Owner (agency owner) - 2-3 Admins (department heads) - 15-20 Members (account managers, creators) - 2-5 Viewers (clients) **Rationale:** Hierarchy with client visibility. ##### 3. Large Organization (50+ people) **Setup:** - 1 Owner (IT administrator) - 5-10 Admins (team leads) - 40+ Members (employees) - 10+ Viewers (stakeholders) **Rationale:** Scaled management with many contributors. ##### 4. Freelancer + Client **Setup:** - 1 Owner (freelancer) - 1-2 Viewers (client stakeholders) **Rationale:** Client can monitor without editing. #### Best Practices ##### 1. Principle of Least Privilege Give minimum permissions needed: **Good:** - Client monitoring campaign → Viewer - Marketing creating links → Member - Team lead managing settings → Admin **Bad:** - Everyone is Admin - Client has Member access ##### 2. Regular Audits Review your team in **Settings → Team** quarterly — remove inactive members and adjust roles to match current responsibilities. ##### 3. Document Role Assignments Keep a record of why roles were assigned: ```markdown ### Team Roles - john@example.com (Owner) - Founder, handles billing - sarah@example.com (Admin) - Marketing lead - mike@example.com (Member) - Content creator - client@nike.com (Viewer) - Client stakeholder ``` ##### 4. Limit Admins Don't make everyone admin: **Recommended:** - Owners: 1 - Admins: 10-20% of team - Members: 70-80% of team - Viewers: Case-by-case #### Troubleshooting ##### "You don't have permission" **Cause:** Your role doesn't allow this action **Fix:** 1. Check your role (profile menu) 2. Contact admin/owner to elevate permissions 3. Ask admin to perform action ##### Can't Change Own Role **Cause:** Can't demote yourself **Fix:** Ask another admin/owner to change your role ##### Can't Remove Owner **Cause:** Owner can't be removed **Fix:** Owner must transfer ownership first, then leave #### Security ##### Access Control - **Role verification** on every API call - **UI enforcement** hides unauthorized actions - **Audit logging** tracks role changes (Unlimited) ##### Recommendations - Regularly review team members - Remove inactive accounts - Use Viewer role for read-only access - Require 2FA for Admins and Owners (Unlimited) #### Related Guides - [Organizations](https://docs.linkforty.com/guides/team/organizations/) - Organization management - [Inviting Members](https://docs.linkforty.com/guides/team/inviting-members/) - Add team members - [Projects](https://docs.linkforty.com/guides/team/projects/) - Organize with projects #### Next Steps 1. Review current team roles 2. Assign appropriate permissions 3. Document role decisions 4. Set up regular permission audits --- ## Inviting Members Source: https://docs.linkforty.com/guides/team/inviting-members/ **Info: Cloud Feature** Team invitations are a Cloud-only feature. Self-hosted Core deployments are single-tenant and do not include team management. See [Cloud vs Self-Hosted](https://docs.linkforty.com/getting-started/cloud-vs-self-hosted/) for details. Add team members to your organization with email invitations and role assignments. Team management is done in the LinkForty dashboard. #### Overview LinkForty's team invitation system lets you: - Send email invitations to colleagues - Assign roles before members join - Track pending, accepted, and declined invitations - Rely on automatic expiration (7 days) **Key Concepts:** - **Invitation** - Email invite with a unique token and assigned role - **Pending** - Invitation sent but not yet responded to - **Accepted** - Member joined the organization - **Declined** - Invitation rejected by the recipient - **Expired** - Invitation passed its 7-day validity period #### Sending Invitations 1. Go to **Settings → Team** 2. Click **"Invite Member"** 3. Enter the email address 4. Select a role (Admin, Member, or Viewer) 5. Click **"Send Invitation"** The recipient receives an email with an invitation link. **Requires:** Admin or Owner role. **Note:** You cannot assign the "Owner" role via invitation — only an existing Owner can transfer ownership. #### Invitation Flow ``` 1. Admin/Owner sends invitation ↓ 2. Email sent to recipient ↓ 3. Recipient clicks the link ↓ 4. Logs in or creates an account ↓ 5. Accepts or declines the invitation ↓ 6. [If accepted] Added to the organization ``` #### Managing Invitations ##### Viewing Pending Invitations Go to **Settings → Team → Pending Invitations** to see all sent invitations with their status (pending, accepted, declined, expired), assigned role, who sent them, and when they expire. ##### Canceling Invitations In **Settings → Team → Pending Invitations**, click **"Cancel"** next to an invitation and confirm. Cancel an invitation when it was sent to the wrong email, the role was wrong, or the person is no longer joining. **Requires:** Admin or Owner role. #### Accepting and Declining A recipient accepts or declines from the invitation email: 1. Click the invitation link in the email 2. Log in (or create an account if new) 3. Review the organization details 4. Click **"Accept Invitation"** or **"Decline Invitation"** **Note:** The logged-in account's email must match the invited email to accept. #### Invitation Statuses | Status | Description | Can Resend? | |--------|-------------|-------------| | **pending** | Sent, awaiting response | No (cancel first) | | **accepted** | Member joined the organization | N/A | | **declined** | Recipient rejected the invitation | Yes | | **expired** | 7 days passed without a response | Yes | #### Limits and Restrictions ##### Member Limits by Plan | Plan | Max Members | Max Pending Invitations | |------|-------------|-------------------------| | **Free** | 3 | 0 (upgrade required) | | **Pro** | 10 | 10 | | **Business** | 25 | 25 | | **Unlimited** | Unlimited | Unlimited | ##### Invitation Rules - Roles you can invite to: Admin, Member, Viewer - Cannot invite as Owner (transfer ownership after joining instead) - Can invite an email that isn't registered yet - Cannot invite an existing organization member - Cannot send duplicate invitations to the same email - Invitations expire after 7 days - Can resend after cancellation or expiration #### Best Practices ##### Use Descriptive Role Assignments - **Admin** — team leads who manage settings - **Member** — content creators and marketers - **Viewer** — clients and stakeholders who need read-only access Avoid making everyone an Admin "just in case," and prefer Viewer over Member when read-only is sufficient. ##### Review Invitations Regularly Check **Settings → Team → Pending Invitations** periodically. A pile of pending invites may mean email deliverability issues or that follow-up is needed. ##### Clean Up Old Invitations Cancel stale pending invitations and re-send if the person still needs access. #### Troubleshooting ##### "User is already a member" The email is already in the organization. Check the members list; if their role is wrong, change the role instead of re-inviting. ##### "Invitation already sent" A pending invitation already exists for this email. Cancel it first, then send a new one. ##### "Invitation has expired" More than 7 days passed. Cancel the expired invitation and send a new one; the recipient must accept within 7 days. ##### "Member limit reached" Your organization hit its plan's member limit. Remove inactive members or upgrade your plan (see **Settings → Billing**). ##### "Email doesn't match" The logged-in user's email differs from the invited email. Log out and sign in (or register) with the invited email, then accept. #### Invitation Email Recipients receive an email containing: - The inviter's name and the organization name - The assigned role (Admin, Member, or Viewer) - A call-to-action button with the invitation link - An expiration notice (7 days) #### Security ##### Email Verification - **Token-based** — a unique, cryptographically secure token per invitation - **Email matching** — the recipient must sign in with the invited email - **Expiration** — 7-day validity period - **Single-use** — the token is invalidated after acceptance or decline ##### Access Control - Only Admins and Owners can send invitations - Only the invited email can accept the invitation - Invitation details are hidden after expiration #### Related Guides - [Organizations](https://docs.linkforty.com/guides/team/organizations/) - Creating and managing organizations - [Roles & Permissions](https://docs.linkforty.com/guides/team/roles-permissions/) - Understanding access control - [Projects](https://docs.linkforty.com/guides/team/projects/) - Organizing work with projects #### Next Steps 1. Invite your first team member 2. Assign appropriate roles based on responsibilities 3. Track invitation acceptance in **Settings → Team** --- ## Projects Source: https://docs.linkforty.com/guides/team/projects/ **Info: Cloud Feature** Projects are a Cloud-only feature. Self-hosted Core deployments do not include project-based link organization. See [Cloud vs Self-Hosted](https://docs.linkforty.com/getting-started/cloud-vs-self-hosted/) for details. Organize links into projects for better management and campaign tracking. #### Overview **Projects** are folders that help you organize links within your organization. They're perfect for: - Grouping links by campaign - Separating client work - Organizing by department or team - Tracking project-specific analytics **Key Benefits:** - **Organization** - Group related links together - **Analytics** - View aggregated metrics per project - **Filtering** - Quickly find links by project - **Collaboration** - Share project context with team #### Creating Projects ##### Via Dashboard 1. Go to **Projects** in the sidebar 2. Click **"New Project"** 3. Enter project name 4. (Optional) Add description 5. Click **"Create"** ##### Via API ```bash curl -X POST https://api.linkforty.com/api/projects \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Spring Sale 2024", "description": "Instagram and email marketing campaign for Q1 spring sale" }' ``` **Response:** ```json { "id": "proj_abc123", "user_id": "user_456", "organization_id": "org_789", "name": "Spring Sale 2024", "description": "Instagram and email marketing campaign for Q1 spring sale", "created_at": "2024-03-15T10:30:00Z", "updated_at": "2024-03-15T10:30:00Z" } ``` **Requires:** Member, Admin, or Owner role #### Managing Projects ##### Viewing All Projects **Dashboard:** - Click **"Projects"** in sidebar - See all projects with link counts **API:** ```bash curl https://api.linkforty.com/api/projects \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Response:** ```json [ { "id": "proj_123", "name": "Spring Sale 2024", "description": "Q1 spring sale campaign", "created_at": "2024-03-15T10:30:00Z", "updated_at": "2024-03-15T10:30:00Z" }, { "id": "proj_456", "name": "Product Launch", "description": "New product launch links", "created_at": "2024-02-01T14:00:00Z", "updated_at": "2024-02-10T09:15:00Z" } ] ``` ##### Getting Project Details **API:** ```bash curl https://api.linkforty.com/api/projects/proj_abc123 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Response:** ```json { "id": "proj_abc123", "user_id": "user_456", "organization_id": "org_789", "name": "Spring Sale 2024", "description": "Instagram and email marketing campaign for Q1 spring sale", "created_at": "2024-03-15T10:30:00Z", "updated_at": "2024-03-15T10:30:00Z" } ``` ##### Updating Projects **Dashboard:** 1. Go to **Projects** 2. Click project name 3. Click **"Edit"** 4. Update name or description 5. Click **"Save"** **API:** ```bash curl -X PUT https://api.linkforty.com/api/projects/proj_abc123 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Spring Sale 2024 - Extended", "description": "Extended through April due to success" }' ``` **Requires:** Member, Admin, or Owner role ##### Deleting Projects **Warning:** Deleting a project does NOT delete the links inside. Links are unassigned and remain in your organization. **Dashboard:** 1. Go to **Projects** 2. Click project 3. Click **"Settings"** → **"Delete Project"** 4. Confirm deletion **API:** ```bash curl -X DELETE https://api.linkforty.com/api/projects/proj_abc123 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Response:** ```json { "success": true } ``` **Requires:** Admin or Owner role (for deletion) #### Assigning Links to Projects ##### During Link Creation **Dashboard:** 1. Click **"Create Link"** 2. Select project from dropdown 3. Fill in link details 4. Click **"Create"** **API:** ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "originalUrl": "https://shop.example.com/spring-sale", "projectId": "proj_abc123", "title": "Instagram Story - Spring Sale" }' ``` ##### For Existing Links **Dashboard:** 1. Go to **Links** 2. Click link to edit 3. Select project from dropdown 4. Click **"Save"** **API:** ```bash curl -X PUT https://api.linkforty.com/api/links/link_xyz789 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "projectId": "proj_abc123" }' ``` ##### Filtering Links by Project **Dashboard:** 1. Go to **Links** 2. Use project filter dropdown 3. Select project **API:** ```bash curl "https://api.linkforty.com/api/links?projectId=proj_abc123" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` #### Project Analytics View aggregated analytics for all links in a project. ##### Via Dashboard 1. Go to **Projects** 2. Click project name 3. View analytics tab ##### Via API ```bash curl "https://api.linkforty.com/api/analytics/projects/proj_abc123?days=30" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Response:** ```json { "totalClicks": 5432, "uniqueClicks": 3821, "clicksByDate": [ { "date": "2024-03-15", "clicks": 245 }, { "date": "2024-03-14", "clicks": 198 } ], "clicksByCountry": [ { "country": "US", "clicks": 2341 }, { "country": "CA", "clicks": 987 } ], "clicksByDevice": [ { "device": "mobile", "clicks": 3876 }, { "device": "desktop", "clicks": 1234 }, { "device": "tablet", "clicks": 322 } ], "clicksByPlatform": [ { "platform": "iOS", "clicks": 2145 }, { "platform": "Android", "clicks": 1731 }, { "platform": "Windows", "clicks": 1556 } ] } ``` **Query Parameters:** - `days` - Number of days to include (default: 30, max: 365) #### Common Workflows ##### 1. Campaign Organization **Setup:** ```typescript async function setupCampaignProjects() { const campaigns = [ { name: 'Q1 Email Campaign', description: 'Weekly newsletter links' }, { name: 'Instagram Ads - March', description: 'Paid social media ads' }, { name: 'Product Launch', description: 'New product announcement' }, ]; for (const campaign of campaigns) { await fetch('https://api.linkforty.com/api/projects', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify(campaign), }); } } ``` ##### 2. Agency Client Organization **Pattern:** ``` Organization: Marketing Agency ├── Project: Client - Nike ├── Project: Client - Adidas ├── Project: Client - Under Armour └── Project: Internal - Agency Website ``` **Code:** ```typescript async function createClientProject(clientName: string) { const response = await fetch('https://api.linkforty.com/api/projects', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: `Client - ${clientName}`, description: `All links and campaigns for ${clientName}`, }), }); return response.json(); } ``` ##### 3. Department Organization **Pattern:** ``` Organization: Acme Corp ├── Project: Marketing Team ├── Project: Sales Team ├── Project: Product Team └── Project: Customer Success ``` ##### 4. Time-Based Projects **Pattern:** ``` Organization: E-commerce Store ├── Project: January 2024 ├── Project: February 2024 ├── Project: March 2024 └── Project: Q1 Summary ``` #### TypeScript Examples ##### 1. Create Project and Add Links ```typescript interface Project { id: string; name: string; description?: string; created_at: string; updated_at: string; } interface Link { id: string; shortCode: string; originalUrl: string; projectId?: string; title?: string; } async function createProjectWithLinks( projectName: string, links: Array<{ url: string; title: string }> ): Promise<{ project: Project; links: Link[] }> { // Create project const projectResponse = await fetch('https://api.linkforty.com/api/projects', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: projectName }), }); const project = await projectResponse.json(); // Create links assigned to project const createdLinks = []; for (const link of links) { const linkResponse = await fetch('https://api.linkforty.com/api/links', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ originalUrl: link.url, title: link.title, projectId: project.id, }), }); createdLinks.push(await linkResponse.json()); } return { project, links: createdLinks }; } // Usage const campaign = await createProjectWithLinks('Spring Sale', [ { url: 'https://shop.com/sale', title: 'Instagram Story 1' }, { url: 'https://shop.com/sale', title: 'Email Newsletter' }, { url: 'https://shop.com/sale', title: 'Facebook Ad' }, ]); console.log(`Created project: ${campaign.project.name}`); console.log(`Created ${campaign.links.length} links`); ``` ##### 2. Compare Project Performance ```typescript async function compareProjectPerformance( projectIds: string[], days: number = 30 ) { const results = await Promise.all( projectIds.map(async id => { const analyticsResponse = await fetch( `https://api.linkforty.com/api/analytics/projects/${id}?days=${days}`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ); const analytics = await analyticsResponse.json(); const projectResponse = await fetch( `https://api.linkforty.com/api/projects/${id}`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ); const project = await projectResponse.json(); return { projectId: id, projectName: project.name, totalClicks: analytics.totalClicks, uniqueClicks: analytics.uniqueClicks, clickThroughRate: ( (analytics.uniqueClicks / analytics.totalClicks) * 100 ).toFixed(1) + '%', }; }) ); // Sort by total clicks return results.sort((a, b) => b.totalClicks - a.totalClicks); } // Usage const comparison = await compareProjectPerformance([ 'proj_123', 'proj_456', 'proj_789', ]); console.table(comparison); ``` ##### 3. Archive Old Projects ```typescript async function archiveOldProjects(monthsOld: number = 6) { const response = await fetch('https://api.linkforty.com/api/projects', { headers: { 'Authorization': `Bearer ${API_KEY}` }, }); const projects: Project[] = await response.json(); const cutoffDate = new Date(); cutoffDate.setMonth(cutoffDate.getMonth() - monthsOld); const oldProjects = projects.filter(project => { const createdAt = new Date(project.created_at); return createdAt < cutoffDate; }); for (const project of oldProjects) { // Check if project has any links const linksResponse = await fetch( `https://api.linkforty.com/api/links?projectId=${project.id}`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ); const links = await linksResponse.json(); if (links.length === 0) { // Delete empty old projects await fetch(`https://api.linkforty.com/api/projects/${project.id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${API_KEY}` }, }); console.log(`Deleted empty project: ${project.name}`); } else { // Rename to mark as archived await fetch(`https://api.linkforty.com/api/projects/${project.id}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: `[ARCHIVED] ${project.name}`, }), }); console.log(`Archived project: ${project.name}`); } } } ``` ##### 4. Generate Project Report ```typescript async function generateProjectReport(projectId: string) { // Get project details const projectResponse = await fetch( `https://api.linkforty.com/api/projects/${projectId}`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ); const project = await projectResponse.json(); // Get project analytics const analyticsResponse = await fetch( `https://api.linkforty.com/api/analytics/projects/${projectId}?days=30`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ); const analytics = await analyticsResponse.json(); // Get all links in project const linksResponse = await fetch( `https://api.linkforty.com/api/links?projectId=${projectId}`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ); const links = await linksResponse.json(); return { project: { name: project.name, description: project.description, created: new Date(project.created_at).toLocaleDateString(), }, summary: { totalLinks: links.length, totalClicks: analytics.totalClicks, uniqueClicks: analytics.uniqueClicks, avgClicksPerLink: (analytics.totalClicks / links.length).toFixed(1), avgClicksPerDay: (analytics.totalClicks / 30).toFixed(1), }, topCountries: analytics.clicksByCountry.slice(0, 5), deviceBreakdown: analytics.clicksByDevice, platformBreakdown: analytics.clicksByPlatform, }; } // Usage const report = await generateProjectReport('proj_abc123'); console.log(JSON.stringify(report, null, 2)); ``` ##### 5. Bulk Move Links to Project ```typescript async function moveLinksToProject( linkIds: string[], projectId: string ): Promise<{ success: number; failed: number }> { let success = 0; let failed = 0; for (const linkId of linkIds) { try { await fetch(`https://api.linkforty.com/api/links/${linkId}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ projectId }), }); success++; } catch (error) { console.error(`Failed to move link ${linkId}:`, error); failed++; } } return { success, failed }; } // Usage const result = await moveLinksToProject( ['link_1', 'link_2', 'link_3'], 'proj_abc123' ); console.log(`Moved ${result.success} links`); console.log(`Failed ${result.failed} links`); ``` #### Best Practices ##### 1. Clear Naming Conventions **Good:** - "Q1 2024 - Email Marketing" - "Client: Nike - Spring Campaign" - "Product Launch - New Widget" - "Instagram Ads - March 2024" **Bad:** - "Project 1" - "Test" - "Links" - "Stuff" ##### 2. Use Descriptions ```typescript const project = { name: 'Spring Sale 2024', description: 'Instagram stories and email newsletter links for Q1 spring sale promotion. Target: 10K clicks.' }; ``` ##### 3. Regular Cleanup ```typescript // Monthly cleanup async function monthlyProjectCleanup() { // Archive old projects await archiveOldProjects(6); // Delete empty archived projects older than 1 year await archiveOldProjects(12); } ``` ##### 4. Consistent Organization Scheme Pick one pattern and stick to it: **Option A - By Time:** ``` - Q1 2024 - Q2 2024 - Q3 2024 ``` **Option B - By Campaign:** ``` - Spring Sale - Summer Launch - Black Friday ``` **Option C - By Client (Agencies):** ``` - Client: Nike - Client: Adidas - Client: Puma ``` **Option D - By Channel:** ``` - Email Marketing - Instagram Ads - LinkedIn Posts ``` ##### 5. Don't Over-Organize **When NOT to use projects:** - You have less than 10 links - All links are for one campaign - You're a solo user with simple needs **When to use projects:** - Managing 50+ links - Multiple campaigns or clients - Team collaboration - Need separate analytics per campaign #### Troubleshooting ##### "Project not found" **Cause:** Project ID doesn't exist or not in your organization **Fix:** 1. List all projects: `GET /api/projects` 2. Verify project ID 3. Check organization context ##### Can't Delete Project **Cause:** Insufficient permissions **Fix:** 1. Check your role (Admin or Owner required for deletion) 2. Ask admin to delete project 3. Note: Members can create but not delete projects ##### Links Not Showing in Project **Cause:** Links not assigned to project **Fix:** 1. Edit link 2. Select project from dropdown 3. Save changes ##### Project Analytics Empty **Possible causes:** - No links in project - Links have no clicks yet - Filtering by too short time period **Fix:** 1. Verify links are assigned: `GET /api/links?projectId=proj_123` 2. Check link analytics individually 3. Increase `days` parameter #### Related Guides - [Organizations](https://docs.linkforty.com/guides/team/organizations/) - Organization management - [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) - Link creation with projects - [Analytics Dashboard](https://docs.linkforty.com/guides/analytics/dashboard/) - Understanding analytics #### Next Steps 1. Create your first project 2. Assign existing links to projects 3. Set up a naming convention 4. Review project analytics regularly 5. Archive old projects quarterly --- # Advanced Features ## Webhooks Source: https://docs.linkforty.com/guides/advanced/webhooks/ Receive real-time notifications when events occur in your LinkForty account via HTTP callbacks to your server. **Info: Configured in the dashboard** Webhooks are created and managed in the dashboard under **Settings → Webhooks**. Your application's role is to **receive** deliveries at the URL you register and verify their signatures, as shown below. (API keys are scoped to links, analytics, templates, and projects — see [Authentication](https://docs.linkforty.com/api/authentication/#what-api-keys-can-access).) #### Overview Webhooks allow you to build integrations that subscribe to events in LinkForty. When an event occurs (like a link click or app install), LinkForty sends an HTTP POST request to your configured webhook URL with event data. **Benefits:** - **Real-time** - Get notified immediately when events happen - **Efficient** - No need to poll APIs for updates - **Secure** - HMAC signature verification ensures authenticity - **Reliable** - Automatic retries with exponential backoff - **Flexible** - Subscribe to specific events you care about #### Event Types LinkForty supports four event types: | Event | Description | Triggered When | |-------|-------------|----------------| | `click_event` | User clicked a link | Someone clicks your short link | | `install_event` | App installed | User installs your app (attributed to a link) | | `conversion_event` | Conversion tracked | User completes a conversion event in your app (attributed installs only) | | `sdk_event` | SDK event tracked | Any in-app event is tracked via the SDK (all events regardless of attribution) | #### Creating a Webhook Create and manage webhooks in the dashboard under **Settings → Webhooks**: 1. Click **"Create Webhook"** 2. Configure: - **Name**: Descriptive name (e.g., "Slack Notifications") - **URL**: Your endpoint URL (must be HTTPS) - **Events**: Select which events to receive — `click_event`, `install_event`, `conversion_event`, `sdk_event` - **Custom Headers**: Optional headers to include on each delivery - **Retries** and **timeout**: How aggressively to retry failed deliveries 4. Click **"Create"** 5. **Save the signing secret** (`whsec_…`) — you'll need it to [verify signatures](#verifying-webhook-signatures). It's also available later in the webhook's details view. #### Webhook Payload Every webhook delivery includes: ##### Headers | Header | Description | Example | |--------|-------------|---------| | `Content-Type` | Always `application/json` | `application/json` | | `X-LinkForty-Signature` | HMAC SHA-256 signature | `sha256=a1b2c3...` | | `X-LinkForty-Event` | Event type | `click_event` | | `X-LinkForty-Event-ID` | Unique event identifier | `evt_abc123` | | `User-Agent` | LinkForty webhook agent | `LinkForty-Webhook/1.0` | ##### Body ```json { "event": "click_event", "event_id": "evt_a1b2c3d4e5f6", "timestamp": "2024-03-15T14:23:12Z", "data": { "id": "click_123", "linkId": "link_abc", "shortCode": "abc123", "clickedAt": "2024-03-15T14:23:12Z", "ipAddress": "192.168.1.1", "deviceType": "mobile", "platform": "iOS", "browser": "Safari", "countryCode": "US", "city": "San Francisco", "utmSource": "instagram", "utmMedium": "social", "utmCampaign": "spring-sale", "referrer": "https://www.instagram.com/" } } ``` ##### Event Payloads ###### Click Event ```json { "event": "click_event", "event_id": "evt_123", "timestamp": "2024-03-15T14:23:12Z", "data": { "id": "click_id", "linkId": "link_abc", "shortCode": "abc123", "clickedAt": "2024-03-15T14:23:12Z", "ipAddress": "192.168.1.1", "userAgent": "Mozilla/5.0...", "deviceType": "mobile", "platform": "iOS", "browser": "Safari", "countryCode": "US", "city": "San Francisco", "utmSource": "instagram", "utmCampaign": "spring-sale" } } ``` ###### Install Event ```json { "event": "install_event", "event_id": "evt_456", "timestamp": "2024-03-15T15:45:30Z", "data": { "id": "install_id", "linkId": "link_abc", "shortCode": "abc123", "installedAt": "2024-03-15T15:45:30Z", "firstOpenAt": "2024-03-15T15:46:12Z", "platform": "iOS", "platformVersion": "17.0", "confidenceScore": 0.85, "attributionWindowHours": 168 } } ``` ###### Conversion Event ```json { "event": "conversion_event", "event_id": "evt_789", "timestamp": "2024-03-15T16:20:00Z", "data": { "id": "conversion_id", "linkId": "link_abc", "eventName": "purchase", "eventValue": 29.99, "currency": "USD", "properties": { "productId": "prod_123", "category": "electronics" } } } ``` ###### SDK Event ```json { "event": "sdk_event", "event_id": "evt_012", "timestamp": "2024-03-15T17:10:00Z", "data": { "eventId": "evt_012", "installId": "install_abc", "eventName": "purchase", "eventData": { "productId": "prod_123", "revenue": 29.99, "currency": "USD" }, "timestamp": "2024-03-15T17:10:00Z" } } ``` **Tip: sdk_event vs conversion_event** `sdk_event` fires for **every** in-app event tracked via the SDK, regardless of whether the install is attributed to a link. `conversion_event` only fires for events from **attributed** installs (where the install was matched to a link click). If you want visibility into all SDK-tracked events, subscribe to `sdk_event`. #### Verifying Webhook Signatures **Always verify** webhook signatures to ensure requests are from LinkForty. ##### How It Works LinkForty signs every webhook with HMAC SHA-256: 1. Create HMAC using your webhook secret 2. Hash the raw request body 3. Compare with `X-LinkForty-Signature` header ##### Node.js/TypeScript Example ```typescript import crypto from 'crypto'; import express from 'express'; const WEBHOOK_SECRET = 'whsec_your_secret_here'; app.post('/webhooks/linkforty', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['x-linkforty-signature'] as string; const body = req.body.toString('utf8'); // Verify signature if (!verifyWebhookSignature(body, signature, WEBHOOK_SECRET)) { return res.status(401).send('Invalid signature'); } // Parse and handle event const event = JSON.parse(body); handleWebhookEvent(event); res.status(200).send('OK'); }); function verifyWebhookSignature(payload: string, signature: string, secret: string): boolean { // Remove 'sha256=' prefix const receivedSignature = signature.replace('sha256=', ''); // Compute expected signature const expectedSignature = crypto .createHmac('sha256', secret) .update(payload) .digest('hex'); // Constant-time comparison to prevent timing attacks return crypto.timingSafeEqual( Buffer.from(receivedSignature, 'hex'), Buffer.from(expectedSignature, 'hex') ); } function handleWebhookEvent(event: any) { console.log(`Received ${event.event}:`, event.data); switch (event.event) { case 'click_event': handleClickEvent(event.data); break; case 'install_event': handleInstallEvent(event.data); break; case 'conversion_event': handleConversionEvent(event.data); break; case 'sdk_event': handleSdkEvent(event.data); break; default: console.log('Unknown event type:', event.event); } } ``` ##### Python Example ```python import hmac import hashlib import json from flask import Flask, request, Response WEBHOOK_SECRET = 'whsec_your_secret_here' app = Flask(__name__) @app.route('/webhooks/linkforty', methods=['POST']) def webhook(): signature = request.headers.get('X-LinkForty-Signature', '') body = request.get_data() # Verify signature if not verify_webhook_signature(body, signature, WEBHOOK_SECRET): return Response('Invalid signature', status=401) # Parse and handle event event = json.loads(body) handle_webhook_event(event) return Response('OK', status=200) def verify_webhook_signature(payload: bytes, signature: str, secret: str) -> bool: # Remove 'sha256=' prefix received_signature = signature.replace('sha256=', '') # Compute expected signature expected_signature = hmac.new( secret.encode('utf-8'), payload, hashlib.sha256 ).hexdigest() # Constant-time comparison return hmac.compare_digest(received_signature, expected_signature) def handle_webhook_event(event): print(f"Received {event['event']}: {event['data']}") if event['event'] == 'click_event': handle_click_event(event['data']) elif event['event'] == 'install_event': handle_install_event(event['data']) elif event['event'] == 'conversion_event': handle_conversion_event(event['data']) elif event['event'] == 'sdk_event': handle_sdk_event(event['data']) ``` ##### Go Example ```go package main import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "strings" ) const WebhookSecret = "whsec_your_secret_here" func webhookHandler(w http.ResponseWriter, r *http.Request) { signature := r.Header.Get("X-LinkForty-Signature") body, _ := io.ReadAll(r.Body) // Verify signature if !verifyWebhookSignature(body, signature, WebhookSecret) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return } // Parse and handle event var event WebhookEvent json.Unmarshal(body, &event) handleWebhookEvent(event) w.WriteHeader(http.StatusOK) } func verifyWebhookSignature(payload []byte, signature, secret string) bool { // Remove 'sha256=' prefix receivedSignature := strings.TrimPrefix(signature, "sha256=") // Compute expected signature h := hmac.New(sha256.New, []byte(secret)) h.Write(payload) expectedSignature := hex.EncodeToString(h.Sum(nil)) // Constant-time comparison return hmac.Equal([]byte(receivedSignature), []byte(expectedSignature)) } type WebhookEvent struct { Event string `json:"event"` EventID string `json:"event_id"` Timestamp string `json:"timestamp"` Data map[string]interface{} `json:"data"` } func handleWebhookEvent(event WebhookEvent) { switch event.Event { case "click_event": handleClickEvent(event.Data) case "install_event": handleInstallEvent(event.Data) case "conversion_event": handleConversionEvent(event.Data) case "sdk_event": handleSdkEvent(event.Data) } } ``` #### Testing Webhooks ##### Test from Dashboard 1. Go to **Settings** → **Webhooks** 2. Select your webhook 3. Click **"Send Test Event"** 4. Check your server logs **Test Payload:** ```json { "event": "click_event", "event_id": "test_evt_123", "timestamp": "2024-03-15T14:00:00Z", "data": { "id": "test_click_123", "linkId": "test-link-id", "clickedAt": "2024-03-15T14:00:00Z", "deviceType": "test", "platform": "test", "test": true } } ``` ##### Test Locally with ngrok 1. **Install ngrok:** ```bash npm install -g ngrok ``` 2. **Start your local server:** ```bash node webhook-server.js # Server running on http://localhost:3000 ``` 3. **Create ngrok tunnel:** ```bash ngrok http 3000 ``` 4. **Copy the HTTPS URL:** ``` Forwarding https://abc123.ngrok.io -> http://localhost:3000 ``` 5. **Create a webhook with the ngrok URL:** in **Settings → Webhooks**, create a webhook pointing at your HTTPS ngrok URL (e.g. `https://abc123.ngrok.io/webhooks/linkforty`) and subscribe it to the events you want to test. 6. **Trigger events** by clicking your links 7. **See events in your terminal** in real-time ##### Testing Script ```javascript // test-webhook.js const express = require('express'); const crypto = require('crypto'); const app = express(); const PORT = 3000; const SECRET = process.env.WEBHOOK_SECRET || 'your-secret-here'; app.post('/webhooks/linkforty', express.raw({ type: 'application/json' }), (req, res) => { console.log('\n=== WEBHOOK RECEIVED ==='); console.log('Headers:', req.headers); const signature = req.headers['x-linkforty-signature']; const body = req.body.toString('utf8'); // Verify signature const expectedSig = crypto.createHmac('sha256', SECRET).update(body).digest('hex'); const receivedSig = signature.replace('sha256=', ''); console.log('\n=== SIGNATURE VERIFICATION ==='); console.log('Expected: ', expectedSig); console.log('Received: ', receivedSig); console.log('Valid: ', expectedSig === receivedSig); if (expectedSig !== receivedSig) { console.log('\nINVALID SIGNATURE'); return res.status(401).send('Invalid signature'); } // Parse event const event = JSON.parse(body); console.log('\n=== EVENT DATA ==='); console.log('Event: ', event.event); console.log('Event ID: ', event.event_id); console.log('Timestamp: ', event.timestamp); console.log('Data: ', JSON.stringify(event.data, null, 2)); console.log('\nWEBHOOK PROCESSED\n'); res.status(200).send('OK'); }); app.listen(PORT, () => { console.log(`Webhook server listening on http://localhost:${PORT}`); console.log(`Secret: ${SECRET}\n`); }); ``` **Run it:** ```bash WEBHOOK_SECRET=your_secret_here node test-webhook.js ``` #### Retry Behavior LinkForty automatically retries failed webhook deliveries with exponential backoff. ##### Retry Schedule | Attempt | Delay | Total Time Elapsed | |---------|-------|--------------------| | 1 | Immediate | 0s | | 2 | 1 second | 1s | | 3 | 2 seconds | 3s | | 4 | 4 seconds (if retryCount > 3) | 7s | | 5 | 8 seconds (if retryCount > 4) | 15s | **Default:** 3 retries (configurable 1-10) ##### When Retries Occur Webhooks are retried if: - HTTP status code ≠ 200 - Connection timeout (default: 10 seconds) - Network error - DNS resolution failure ##### When Retries Stop Retries stop when: - Webhook returns HTTP 200 - Maximum retry count reached - Timeout exceeded on all attempts ##### Configuring Retries Set the **retry count** and **timeout** on each webhook in **Settings → Webhooks** when you create or edit it. #### Best Practices ##### 1. Respond Quickly Return `200 OK` immediately, process asynchronously: **Good:** ```typescript app.post('/webhooks/linkforty', async (req, res) => { // Verify signature if (!verifySignature(req.body, req.headers['x-linkforty-signature'])) { return res.status(401).send('Invalid signature'); } // Respond immediately res.status(200).send('OK'); // Process async (don't await) processWebhookAsync(req.body).catch(console.error); }); ``` **Bad:** ```typescript app.post('/webhooks/linkforty', async (req, res) => { // Don't do slow operations before responding await database.processEvent(req.body); // Slow await sendEmail(req.body); // Slow res.status(200).send('OK'); }); ``` ##### 2. Idempotency Handle duplicate events (use `event_id`): ```typescript async function handleWebhookEvent(event: WebhookEvent) { // Check if already processed const exists = await db.checkEventProcessed(event.event_id); if (exists) { console.log('Event already processed:', event.event_id); return; } // Process event await processEvent(event); // Mark as processed await db.markEventProcessed(event.event_id); } ``` ##### 3. Use Queues for Processing ```typescript import { Queue } from 'bull'; const webhookQueue = new Queue('webhooks'); app.post('/webhooks/linkforty', (req, res) => { // Verify signature if (!verifySignature(req.body, req.headers['x-linkforty-signature'])) { return res.status(401).send('Invalid signature'); } // Add to queue webhookQueue.add(req.body); res.status(200).send('OK'); }); // Process queue webhookQueue.process(async (job) => { const event = job.data; await handleWebhookEvent(event); }); ``` ##### 4. Log Everything ```typescript app.post('/webhooks/linkforty', (req, res) => { const startTime = Date.now(); // Log incoming webhook console.log({ type: 'webhook_received', event_id: req.headers['x-linkforty-event-id'], event_type: req.headers['x-linkforty-event'], timestamp: new Date().toISOString(), }); // Verify and process... // Log completion console.log({ type: 'webhook_processed', event_id: req.headers['x-linkforty-event-id'], duration_ms: Date.now() - startTime, }); res.status(200).send('OK'); }); ``` ##### 5. Monitor Failures Check each webhook's **delivery log** in **Settings → Webhooks** regularly to catch endpoints that are timing out or returning errors, and use the dashboard's **"Send test event"** to confirm a fix. #### Use Cases ##### 1. Slack Notifications ```typescript async function handleClickEvent(data: ClickEventData) { await fetch(process.env.SLACK_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: `🔗 New click on ${data.shortCode} from ${data.city}, ${data.countryCode}`, blocks: [ { type: 'section', text: { type: 'mrkdwn', text: `*Link Clicked*\n• Short Code: \`${data.shortCode}\`\n• Location: ${data.city}, ${data.countryCode}\n• Device: ${data.deviceType} (${data.platform})\n• Campaign: ${data.utmCampaign || 'None'}` } } ] }) }); } ``` ##### 2. CRM Integration ```typescript async function handleInstallEvent(data: InstallEventData) { // Add to CRM await crmClient.createLead({ source: 'linkforty', campaign: data.utmCampaign, platform: data.platform, attributedTo: data.shortCode, installedAt: data.installedAt, }); } ``` ##### 3. Analytics Pipeline ```typescript async function handleConversionEvent(data: ConversionEventData) { // Send to analytics await analyticsClient.track({ userId: data.userId, event: data.eventName, properties: { value: data.eventValue, currency: data.currency, linkId: data.linkId, ...data.properties, }, }); } ``` #### Troubleshooting ##### Webhooks Not Receiving Events **Check:** 1. **Webhook is active** - Verify `isActive: true` 2. **Events subscribed** - Ensure you've selected the right events 3. **URL is reachable** - Must be public HTTPS 4. **Server is responding** - Return 200 within timeout 5. **Firewall rules** - Allow LinkForty IPs (if applicable) ##### Signature Verification Failing **Common Issues:** - Using wrong secret - Modifying request body before verification - Not using raw body (express requires `express.raw()`) - Incorrect HMAC algorithm (must be SHA-256) **Debug:** ```typescript console.log('Received Signature:', req.headers['x-linkforty-signature']); console.log('Raw Body:', req.body.toString('utf8')); console.log('Expected Signature:', crypto.createHmac('sha256', secret).update(req.body).digest('hex')); ``` ##### Timeouts **Increase the timeout** for the webhook in **Settings → Webhooks**, or **respond faster** from your endpoint: - Return `200` immediately - Process the payload asynchronously - Use a queue #### Security - **Always verify signatures** - Never trust unverified requests - **Use HTTPS** - Webhooks must use HTTPS URLs - **Keep secrets safe** - Store in environment variables - **Rotate secrets** - If compromised, regenerate - **Whitelist IPs** - Optionally restrict to LinkForty IPs - **Rate limit** - Protect your endpoint from abuse #### Next Steps - Set up your first webhook - Test with ngrok locally - Integrate with your systems - Monitor delivery logs --- ## Integrations - Forward Events to Third-Party Platforms Source: https://docs.linkforty.com/guides/advanced/integrations/ Automatically forward LinkForty events to your existing analytics platforms, messaging tools, and workflow automation services. When someone clicks a link, installs your app, or triggers a conversion, the event data is sent to every connected provider in real time. **Info: Cloud Feature** Analytics integrations are available on **Pro**, **Business**, and **Unlimited** plans. Free plans do not include integrations. [View pricing](https://linkforty.com). **Info: Configured in the dashboard** Integrations are set up and managed in the dashboard under **Settings → Integrations**. (API keys are scoped to links, analytics, templates, and projects — see [Authentication](https://docs.linkforty.com/api/authentication/#what-api-keys-can-access).) #### Overview Analytics integrations complement LinkForty's built-in [analytics dashboard](https://docs.linkforty.com/guides/analytics/dashboard/) by sending the same event data to platforms your team already uses. This lets you: - **Unify your data** — See deep link performance alongside product metrics in a single tool - **Build funnels** — Track the full journey from link click to install to in-app conversion - **Leverage existing workflows** — Use your team's existing dashboards, alerts, and reports - **Reduce data silos** — No manual exports or custom ETL pipelines needed Events are forwarded asynchronously after the HTTP response, so integrations never slow down your link redirects or SDK responses. #### Supported Providers LinkForty supports nine integrations across three categories: analytics platforms, messaging tools, and workflow automation services. ##### Analytics platforms | Provider | Config Required | Best For | |----------|----------------|----------| | [Segment](#segment) | Write Key | Teams that use Segment as a data hub — events route to 400+ downstream tools | | [Mixpanel](#mixpanel) | Project Token | Product analytics, funnels, and retention analysis | | [Amplitude](#amplitude) | API Key | Behavioral analytics, cohort analysis, and experimentation | | [PostHog](#posthog) | API Key + optional Host URL | Open-source product analytics, session replays, and feature flags | | [Google Analytics 4](#google-analytics-4) | Measurement ID + API Secret | Web traffic analysis, conversion tracking, and audience insights | | [CleverTap](#clevertap) | Account ID + Passcode + Region | Mobile engagement analytics, push notifications, and user segmentation | ##### Messaging | Provider | Config Required | Best For | |----------|----------------|----------| | [Slack](#slack) | Incoming Webhook URL | Real-time team notifications when links are clicked, apps install, or conversions happen | ##### Workflow automation | Provider | Config Required | Best For | |----------|----------------|----------| | [Zapier](#zapier) | Webhook URL | Triggering workflows across 5,000+ apps including Google Sheets, HubSpot, and CRMs | | [n8n](#n8n) | Webhook URL + optional Auth Header | Open-source, self-hosted workflow automation as a Zapier alternative | **Tip: Using OneSignal for email?** **OneSignal** also has a dedicated connector that solves a different problem: keeping iOS deep links working when email link tracking would otherwise break them. Unlike the providers above, it isn't about forwarding analytics — see [Email Link Tracking & Deep Links](https://docs.linkforty.com/guides/advanced/email-link-tracking/). #### Event Types Each integration can subscribe to any combination of four event types: | Event | Triggered When | Example Properties | |-------|----------------|-------------------| | `click_event` | Someone clicks a short link | Link ID, device type, platform, browser, country, city, UTM parameters, referrer | | `install_event` | User installs your app (attributed to a link) | Install ID, attribution confidence, deep link data | | `conversion_event` | User completes an in-app event (attributed installs only) | Custom event name, install ID, link ID, event data | | `sdk_event` | Any in-app event tracked via the SDK (all events) | Event name, install ID, link ID, custom event data | #### Setting Up an Integration Integrations are configured in the dashboard under **Settings → Integrations**: 1. Find the provider you want to connect 2. Click **"Configure"** 3. Enter the required credentials (e.g., Write Key, API Key) — see the [provider setup guides](#provider-setup-guides) below 4. Select which event types to forward — `click_event`, `install_event`, `conversion_event`, `sdk_event` 5. Click **"Test Connection"** to verify your credentials 6. Toggle the integration **active** ##### Testing a Connection Use the **"Test Connection"** button when configuring an integration. It sends a small event to the provider and verifies the response, reporting success or the specific error (e.g. "Invalid write key"). For Google Analytics 4, it validates the payload against the debug endpoint before sending. Fix any reported error before activating. #### Provider Setup Guides ##### Segment [Segment](https://segment.com) is a customer data platform that routes events to 400+ downstream tools (analytics, marketing, data warehouses). **Configuration:** | Field | Required | Description | |-------|----------|-------------| | Write Key | Yes | Your Segment source write key | **Where to find it:** Segment dashboard → **Sources** → Select your Node.js source → **Settings** → **API Keys** **Events sent:** | LinkForty Event | Segment Event | Identity | |-----------------|---------------|----------| | Click | `Link Clicked` | `anonymousId` (event ID) | | Install | `App Installed` | `anonymousId` (event ID) | | Conversion | Custom event name or `Conversion` | `anonymousId` (event ID) | | SDK Event | `SDK: {eventName}` | `anonymousId` (event ID) | **Properties mapped:** `linkId`, `shortCode`, `deviceType`, `platform`, `browser`, `country`, `city`, `redirectUrl`, `utmSource`, `utmMedium`, `utmCampaign`, `referer` --- ##### Mixpanel [Mixpanel](https://mixpanel.com) provides product analytics for tracking user behavior, building funnels, and measuring retention. **Configuration:** | Field | Required | Description | |-------|----------|-------------| | Project Token | Yes | Your Mixpanel project token | **Where to find it:** Mixpanel dashboard → **Settings** → **Project Settings** → **Project Token** **Events sent:** | LinkForty Event | Mixpanel Event | Identity | |-----------------|----------------|----------| | Click | `Link Clicked` | `distinct_id` (event ID) | | Install | `App Installed` | `distinct_id` (event ID) | | Conversion | Custom event name or `Conversion` | `distinct_id` (event ID) | | SDK Event | `SDK: {eventName}` | `distinct_id` (event ID) | **Special properties:** Mixpanel's built-in properties are automatically mapped: - `$browser` → browser name - `$os` → platform/OS - `$city` → city name - `$country_code` → country code --- ##### Amplitude [Amplitude](https://amplitude.com) provides behavioral analytics for understanding user journeys, running experiments, and building cohorts. **Configuration:** | Field | Required | Description | |-------|----------|-------------| | API Key | Yes | Your Amplitude project API key | **Where to find it:** Amplitude dashboard → **Settings** → **Projects** → Select your project → **General** → **API Key** **Events sent:** | LinkForty Event | Amplitude Event | Identity | |-----------------|-----------------|----------| | Click | `Link Clicked` | `device_id` (event ID) | | Install | `App Installed` | `device_id` (event ID) | | Conversion | Custom event name or `Conversion` | `device_id` (event ID) | | SDK Event | `SDK: {eventName}` | `device_id` (event ID) | **Note:** Events use `device_id` (not `user_id`) since LinkForty tracks anonymous link interactions. You can connect these to your own user IDs in Amplitude using their identity resolution features. --- ##### PostHog [PostHog](https://posthog.com) is an open-source product analytics platform with session replays, feature flags, and A/B testing. **Configuration:** | Field | Required | Description | |-------|----------|-------------| | API Key | Yes | Your PostHog project API key | | Host URL | No | Custom host for EU Cloud or self-hosted instances | **Where to find it:** PostHog dashboard → **Settings** → **Project** → **Project API Key** **Host URL values:** - **US Cloud** (default): Leave blank or use `https://us.i.posthog.com` - **EU Cloud**: `https://eu.i.posthog.com` - **Self-hosted**: Your PostHog instance URL (e.g., `https://posthog.yourcompany.com`) **Events sent:** | LinkForty Event | PostHog Event | Identity | |-----------------|---------------|----------| | Click | `Link Clicked` | `distinctId` (event ID) | | Install | `App Installed` | `distinctId` (event ID) | | Conversion | Custom event name or `Conversion` | `distinctId` (event ID) | | SDK Event | `SDK: {eventName}` | `distinctId` (event ID) | **Special properties:** PostHog geo properties are explicitly set since server-side events would otherwise resolve to the server's IP: - `$geoip_country_code` → country code - `$geoip_city_name` → city name - `$browser` → browser name - `$os` → platform/OS --- ##### Google Analytics 4 [Google Analytics 4](https://analytics.google.com) provides web and app analytics using the Measurement Protocol for server-side event tracking. **Configuration:** | Field | Required | Description | |-------|----------|-------------| | Measurement ID | Yes | Your GA4 Measurement ID (starts with `G-`) | | API Secret | Yes | A Measurement Protocol API secret | **Where to find them:** - **Measurement ID:** GA4 → **Admin** → **Data Streams** → Select your stream → **Measurement ID** - **API Secret:** GA4 → **Admin** → **Data Streams** → Select your stream → **Measurement Protocol API secrets** → **Create** **Events sent:** | LinkForty Event | GA4 Event | Identity | |-----------------|-----------|----------| | Click | `link_clicked` | `client_id` (event ID) | | Install | `app_installed` | `client_id` (event ID) | | Conversion | Custom event name (snake_case) | `client_id` (event ID) | | SDK Event | `sdk_{eventName}` (snake_case) | `client_id` (event ID) | **GA4-specific behavior:** - Event names use snake_case per GA4 conventions - Event names are truncated to 40 characters (GA4 limit) - The "Test Connection" button uses GA4's debug endpoint, which validates the payload and returns any errors before sending - Parameter values are truncated to 100 characters (GA4 limit) **Properties mapped:** `link_id`, `short_code`, `device_type`, `platform`, `browser`, `country`, `city`, `redirect_url`, `utm_source`, `utm_medium`, `utm_campaign`, `referer` --- ##### CleverTap [CleverTap](https://clevertap.com) is a mobile engagement platform with analytics, push notifications, in-app messaging, and user segmentation. **Configuration:** | Field | Required | Description | |-------|----------|-------------| | Account ID | Yes | Your CleverTap project Account ID | | Passcode | Yes | Your CleverTap project Passcode | | Region | Yes | Data center region: US, Europe, India, Singapore, or Middle East | **Where to find them:** CleverTap Dashboard → **Settings** → **Project** **Region values:** - **US** → `us1.api.clevertap.com` - **Europe** → `eu1.api.clevertap.com` - **India** → `in1.api.clevertap.com` - **Singapore** → `sg1.api.clevertap.com` - **Middle East** → `mec1.api.clevertap.com` **Events sent:** | LinkForty Event | CleverTap Event | Identity | |-----------------|-----------------|----------| | Click | `Link Clicked` | `identity` (event ID) | | Install | `App Installed` | `identity` (event ID) | | Conversion | Custom event name or `Conversion` | `identity` (event ID) | | SDK Event | `SDK: {eventName}` | `identity` (event ID) | **Properties mapped:** `linkId`, `shortCode`, `deviceType`, `platform`, `browser`, `country`, `city`, `redirectUrl`, `utmSource`, `utmMedium`, `utmCampaign`, `referer`, plus install-specific fields like `installId`, `attributed`, `confidenceScore`, and `deepLinkData`. --- ##### Slack [Slack](https://slack.com) is the most common workspace messaging tool. Use this integration to get real-time channel notifications whenever a link is clicked, an install is attributed, or a conversion happens — no dashboard required. **Configuration:** | Field | Required | Description | |-------|----------|-------------| | Incoming Webhook URL | Yes | A Slack Incoming Webhook URL (must contain `hooks.slack.com`) | **Where to find it:** Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps), enable **Incoming Webhooks**, add a webhook to your workspace, and copy the URL. **Notification format:** Slack messages use Slack's [Block Kit](https://api.slack.com/block-kit) for rich formatting. Each message includes a UTC timestamp in the context line. - **Click event** — `:link: *Link Clicked* — short_code · country · platform (browser) · via utm_source` - **Install event** — `:iphone: *App Installed* — attributed: ✅, confidence: 94%` - **Conversion event** — `:dart: *event_name* from link short_code` - **SDK event** — `:gear: *SDK: event_name*` **Tip: Channel routing** Use a separate Slack webhook URL per channel if you want different event types in different channels. Connect LinkForty multiple times — once per channel — and subscribe each integration to a different combination of event types. --- ##### Zapier [Zapier](https://zapier.com) is the most popular no-code automation platform. Use this integration to trigger Zaps across 5,000+ apps including Google Sheets, HubSpot, Mailchimp, Notion, Airtable, and CRMs. **Configuration:** | Field | Required | Description | |-------|----------|-------------| | Webhook URL | Yes | A Zapier "Catch Hook" webhook URL (must contain `hooks.zapier.com`) | **Setup:** 1. In Zapier, create a new Zap 2. Choose **"Webhooks by Zapier"** as the trigger 3. Select **"Catch Hook"** as the event 4. Copy the generated webhook URL 5. Paste it into the LinkForty integration config 6. Use the **Test Connection** button to send a sample event 7. Return to Zapier and click **"Test trigger"** to load the sample data 8. Map the fields to whatever action you want **Payload format:** Events are sent as flat key-value JSON for easy field mapping in Zapier: ```json { "event": "click_event", "event_id": "evt_abc123", "timestamp": "2026-04-08T05:30:00Z", "organization_id": "org_xyz789", "link_id": "lnk_def456", "short_code": "spring-sale", "device_type": "mobile", "platform": "iOS", "browser": "Safari", "country": "US", "city": "San Francisco", "redirect_url": "https://example.com/sale", "utm_source": "instagram", "utm_medium": "social", "utm_campaign": "spring-sale", "utm_term": null, "utm_content": null, "referer": "https://www.instagram.com/" } ``` The payload shape varies by event type — install events include `install_id`, `attributed`, `confidence_score`, and `deep_link_data`; conversion and SDK events include `event_name` and a serialized `event_data` JSON string. --- ##### n8n [n8n](https://n8n.io) is an open-source, self-hosted workflow automation platform — the privacy-first alternative to Zapier. Use this integration when you want to keep event data on infrastructure you control. **Configuration:** | Field | Required | Description | |-------|----------|-------------| | Webhook URL | Yes | The production webhook URL of an n8n workflow with a Webhook trigger node | | Authorization Header | No | Full Authorization header value (e.g., `Bearer your-token`) if your n8n instance requires authentication | **Setup:** 1. In n8n, create a new workflow 2. Add a **Webhook** node as the trigger 3. Set the HTTP method to `POST` and choose `JSON` as the response mode 4. Save and activate the workflow 5. Copy the **production** webhook URL (not the test URL) 6. Paste it into the LinkForty integration config 7. Add an Authorization header value if your n8n instance is protected 8. Use **Test Connection** to send a sample event **Payload format:** The same flat key-value JSON format used by Zapier (see above). This makes it easy to access fields directly in subsequent n8n nodes via expressions like `{{$json.short_code}}` or `{{$json.country}}`. **Why use n8n instead of Zapier?** - Self-hosted — events never leave your infrastructure - No per-task pricing — unlimited executions - Open source (Sustainable Use License) - Works with any custom internal API - Integrates with hundreds of services natively #### How It Works When a LinkForty event occurs: 1. The link redirect (or SDK endpoint) responds to the user immediately 2. After the response, the event is forwarded to the integration system asynchronously 3. LinkForty queries all active integrations for the organization 4. For each integration, it checks if the event type is in the integration's subscribed events 5. Matching providers receive the event via their SDK or API 6. Errors are logged but never block or slow down the main request ``` User clicks link │ ▼ ┌─────────────────┐ │ Redirect user │ ◀── Responds immediately └───────┬─────────┘ │ (async, non-blocking) ▼ ┌─────────────────┐ │ Event forwarder │ └───────┬─────────┘ │ ┌────┼────┬──────┬──────┬──────┬──────┬──────┬──────┐ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼ Segment Mixpanel Amplitude PostHog GA4 CleverTap Slack Zapier n8n ``` #### Plan Limits The number of integrations you can configure depends on your plan: | Plan | Max Integrations | |------|-----------------| | Free | 0 (not available) | | Pro | 3 | | Business | 10 | | Unlimited | Unlimited | Each provider counts as one integration. For example, connecting both Segment and Mixpanel uses 2 of your plan's integration slots. #### Managing Integrations All of these are done from **Settings → Integrations** in the dashboard: - **Update configuration** — change credentials or which events are forwarded - **Enable / disable** — toggle an integration on or off without removing its configuration - **Delete** — remove an integration and its stored credentials - **Review** — see each connected provider, the events it forwards, and its last-synced time **Tip: Security** Sensitive configuration values (API keys, secrets, tokens) are masked in the dashboard — only the first and last 4 characters are shown. #### Best Practices ##### 1. Start with the Events You Need Don't enable all three event types by default. If you only care about click data, subscribe to `click_event` only. This reduces noise in your analytics platform and keeps your data clean. ##### 2. Test Before Activating Always use the "Test Connection" button after configuring an integration. This sends a test event and verifies your credentials are correct before any real events flow through. ##### 3. Use Segment as a Hub If you use multiple analytics tools, consider connecting LinkForty to Segment only. Segment can then route events to Mixpanel, Amplitude, GA4, and hundreds of other tools without needing individual integrations for each. ##### 4. Monitor Last Synced Time The dashboard shows when each integration last successfully forwarded an event. If this timestamp stops updating, check the error message — it may indicate expired credentials or a provider outage. ##### 5. Coordinate with Webhooks Integrations and [webhooks](https://docs.linkforty.com/guides/advanced/webhooks/) serve different purposes: - **Integrations** send events to analytics platforms in their native format - **Webhooks** send raw event data to your own servers for custom processing You can use both simultaneously. Events are forwarded to all active integrations and webhooks independently. #### Troubleshooting ##### Integration Shows Error Status **Check the error message** in the dashboard or via the API. Common causes: - **Invalid credentials** — API key was rotated or revoked - **Provider outage** — The analytics platform is temporarily unavailable - **Rate limiting** — Your analytics provider is throttling requests **Fix:** Update credentials, wait for the provider to recover, or contact the provider about rate limits. The error clears automatically on the next successful event. ##### Events Not Appearing in Provider 1. **Verify the integration is active** — Check the toggle is on 2. **Check subscribed events** — Make sure the event type you expect is enabled 3. **Check the provider's live/debug view** — Most platforms have a real-time event viewer 4. **Wait a moment** — Some providers batch events and show them with a short delay 5. **Test the connection** — Use the test button to confirm credentials still work ##### Test Connection Succeeds But Events Don't Appear The test sends a synthetic event. If real events don't appear: - **Verify link clicks are happening** — Check the LinkForty analytics dashboard first - **Check event type subscriptions** — The integration may only subscribe to `install_event` but you're clicking links (`click_event`) - **Check provider filters** — Some platforms filter test or anonymous events by default #### Next Steps - Set up your first integration from the [Integrations page](https://app.linkforty.com) - View event data in the [analytics dashboard](https://docs.linkforty.com/guides/analytics/dashboard/) - Configure [webhooks](https://docs.linkforty.com/guides/advanced/webhooks/) for custom server-side processing - Export historical data via [data export](https://docs.linkforty.com/guides/analytics/data-export/) --- ## Email Link Tracking & Deep Links - Keep iOS Deep Links Working Source: https://docs.linkforty.com/guides/advanced/email-link-tracking/ Email link tracking and mobile deep links conflict. When your email service provider (ESP) rewrites links to track clicks, it wraps them in a redirect through its own domain — and that redirect **breaks iOS Universal Links in Chrome and in-app browsers**, sending users to the App Store or web instead of opening your app. This guide explains why that happens and how LinkForty fixes it: link to LinkForty **directly** (no ESP wrapping) and let LinkForty backfill the click analytics your ESP would have recorded. **Info: Cloud Feature** ESP click-sync is a **LinkForty Cloud** feature, configured under **Settings → Integrations**. The underlying deep-link fix (linking directly, without ESP wrapping) works on any LinkForty plan and any ESP. #### The Problem A LinkForty link is a [Universal Link](https://docs.linkforty.com/learn/universal-links-vs-app-links/) — tapping it opens your app directly when installed. iOS has one hard rule: **a Universal Link does not fire from an HTTP redirect** unless the browser decides to honor "user intent." When ESP click tracking is on, every link in your email is rewritten to the ESP's tracking domain. So the user taps the ESP domain first, which `302`-redirects to your LinkForty link. That redirect is exactly what breaks Universal Links — and browsers disagree on how to handle it: | Browser | Behavior on a redirect to a Universal Link | |---------|--------------------------------------------| | **Safari** | Lenient — follows the redirect and still opens the app | | **Chrome on iOS** | Only opens the app on a *direct tap* of the link — a redirect falls through to the web/App Store | | **In-app browsers** (Gmail, Instagram, Facebook, Outlook…) | Same as Chrome — the app does **not** open | The result: an installed app opens the App Store anyway for a large share of your users. This is a well-known gap that every deep-linking vendor has to engineer around. ``` Email link (tracking ON) │ user taps ESP tracking domain ▼ ┌──────────────────────┐ │ ESP tracking domain │ logs the click └──────────┬───────────┘ │ 302 redirect ▼ ┌──────────────────────┐ │ LinkForty link │ ← Universal Link, but reached via redirect └──────────┬───────────┘ │ ┌────┴───────────────┐ ▼ ▼ Safari Chrome / in-app opens app ❌ App Store / web ``` #### The Fix Stop letting the ESP wrap the link. Put the **LinkForty link directly** in the email so the user taps it as a direct link — which fires the Universal Link in *every* browser. You're not giving up click tracking. LinkForty already records every click in its own [analytics](https://docs.linkforty.com/guides/analytics/dashboard/), and the ESP click-sync connector pushes each click **back into your ESP** so its dashboards stay populated. ``` Email link (tracking OFF) │ user taps LinkForty link directly ▼ ┌──────────────────────┐ │ LinkForty link │ ← direct tap → Universal Link fires └──────────┬───────────┘ │ ┌─────────────────────┐ ├── opens app ✅ │ (async, non-blocking) │ │ in Safari, Chrome, │ click-sync → ESP │ │ and in-app browsers └─────────────────────┘ ▼ records click → LinkForty analytics → ESP ``` #### Prerequisite: Turn Off ESP Click Tracking **Warning: Required** You **must disable your ESP's link/click tracking** for the emails that contain LinkForty links. If it stays on, the ESP re-wraps your link and the deep link breaks again — the fix does not work with ESP tracking on. Disabling click tracking only affects *click* data on the ESP side — open tracking is unaffected. And LinkForty backfills the click data, so you don't lose it. **Note: What is and isn't preserved** The click-sync sends each click to your ESP as a **custom event**, which keeps your conversion and engagement reporting populated. It does **not** repopulate the ESP's native email **click-through-rate (CTR/CTOR)** widget on the message report — that metric is tied to the ESP's own link wrapping, which you've turned off. Your authoritative click numbers live in the [LinkForty analytics dashboard](https://docs.linkforty.com/guides/analytics/dashboard/). #### Set Up with OneSignal OneSignal is the first supported ESP click-sync connector. The same two-part pattern — *link directly* + *connect the ESP* — applies to any provider. ##### 1. Connect the OneSignal connector In the dashboard, go to **Settings → Integrations**, find **OneSignal**, and click **Configure**: | Field | Required | Description | |-------|----------|-------------| | OneSignal App ID | Yes | OneSignal → **Settings → Keys & IDs** → App ID | | REST API Key | Yes | OneSignal → **Settings → Keys & IDs** → REST API Key (server-side secret) | | Custom event name | No | Name recorded in OneSignal for each click. Defaults to `linkforty_click` | Click **Test Connection** to verify the credentials, then toggle the integration **active**. **Info: This is *your* OneSignal app** These credentials connect *your* OneSignal account so LinkForty can write click events back into it. This is unrelated to any email LinkForty itself sends. ##### 2. Turn off OneSignal click tracking When composing the email (or in the template settings), **uncheck click tracking**. With it on, OneSignal rewrites your links to its own tracking domain, which breaks deep links in Chrome and in-app browsers — the exact problem this connector fixes. ##### 3. Add the recipient's ID to each LinkForty link So LinkForty can attribute the click back to the right OneSignal user, append the subscriber's OneSignal ID to each LinkForty link as an `os_id` query parameter, using OneSignal's Liquid syntax: ``` https://go.yourdomain.com/your-link?os_id={{ user.onesignal_id }} ``` Use `&os_id=...` instead of `?os_id=...` if the link already has query parameters. `{{ user.onesignal_id }}` is rendered by OneSignal per recipient at send time and is present for every user — no `external_id` setup required. LinkForty reads `os_id` from the click and maps it to OneSignal's `onesignal_id`. That's it. When a subscriber taps the link: 1. The app opens directly (Safari, Chrome, and in-app browsers). 2. LinkForty records the click in its own analytics. 3. LinkForty sends a `linkforty_click` custom event to OneSignal, attributed to that user. #### Don't Run Both at Once Leaving ESP click tracking **on** *and* enabling the connector is self-defeating: - **The deep link breaks** — the ESP re-wraps the link, so you're back to the original problem in non-Safari browsers. - **Redundant reporting** — every click is recorded twice: once by the ESP's native tracking and once by the connector's custom event. They live in different reports (so the native click count isn't directly inflated), but if you mark the custom event as a conversion alongside native clicks, your conversion numbers will double-count. Treat the connector as a **replacement** for the ESP's disabled click tracking, not an addition. #### How It Works 1. A subscriber taps the LinkForty link (no ESP wrapping). The app opens via the Universal Link. 2. LinkForty records the click and serves the redirect immediately — the backfill never slows it down. 3. LinkForty reads the identity from the link (`os_id`) and sends a custom event to your ESP, attributed to that subscriber, with automatic retries. Delivery failures are surfaced on the integration and never affect the redirect. Links without an identity, and workspaces with no connector configured, do no extra work. #### Other Email Providers The **deep-link fix is ESP-agnostic**: any provider that wraps links in a tracking redirect breaks iOS Universal Links the same way, and the cure is always the same — *disable that provider's click tracking and link to LinkForty directly*. That works today regardless of which ESP you use. The **analytics backfill** is per-provider — each one connects in **Settings → Integrations** and reads a different identity parameter that you add to your LinkForty links. Supported connectors: | Provider | Identity parameter to add | Merge tag | |----------|---------------------------|-----------| | OneSignal | `?os_id=` | `{{ user.onesignal_id }}` | | Braze | `?braze_id=` | `{{${braze_id}}}` | | Iterable | `?iterable_id=` | `{{userId}}` | | MoEngage | `?moe_id=` | your user-id personalization token | | CleverTap | `?ct_id=` | your CleverTap Identity | Each backfills a custom event (default name `linkforty_click`) into that provider, attributed to the subscriber. The setup is identical to OneSignal above — only the credentials and identity parameter differ; the in-dashboard connector spells out exactly what to add. **Note: CleverTap does both** CleverTap is also an [analytics integration](https://docs.linkforty.com/guides/advanced/integrations/). One CleverTap connection can forward analytics events **and** do email click-sync — pick the events you want (including *Email click sync*) when you configure it. If your provider isn't listed yet, you still get working deep links plus full click analytics in LinkForty — you just won't see the clicks mirrored back into that provider until a connector exists. #### Troubleshooting ##### App still opens the App Store on Chrome / Gmail - **ESP click tracking is still on.** This is the most common cause — the link is being re-wrapped. Disable click tracking for that email. - **Confirm the email contains the raw LinkForty link**, not an ESP-shortened or wrapped URL. - **Verify Universal Links are configured** for your app and link domain — see [Universal Links vs App Links](https://docs.linkforty.com/learn/universal-links-vs-app-links/). ##### Clicks aren't appearing in your ESP 1. **Check the link carries the identity parameter** for your provider (e.g. `?os_id=...` for OneSignal, `?braze_id=...` for Braze — see the table above). Without an identity there's nothing to attribute, and the event is skipped. 2. **Check the integration is active** and **Test Connection** passes. 3. **Check the integration's error message** in the dashboard — an invalid API key or account/endpoint surfaces here. 4. Some clicks are from users the ESP doesn't recognize; those are expected and don't disable the integration. ##### Connection test fails - **401 / 403** — the REST API key is wrong for this app. - **404** — the App ID is incorrect. #### Next Steps - Review all event-forwarding [integrations](https://docs.linkforty.com/guides/advanced/integrations/) - See your authoritative click data in the [analytics dashboard](https://docs.linkforty.com/guides/analytics/dashboard/) - Learn how [Universal Links and App Links](https://docs.linkforty.com/learn/universal-links-vs-app-links/) decide when your app opens --- ## MCP Server - Connect AI Assistants to LinkForty Source: https://docs.linkforty.com/guides/advanced/mcp-server/ Connect Claude, Cursor, Claude Code, and any other MCP-compatible AI tool to your LinkForty workspace. Manage links, query analytics, and integrate the mobile SDK using natural language. **Tip: Open Source** The LinkForty MCP server is open source and free to use on every plan, including Free. Source code: [github.com/linkforty/mcp-server](https://github.com/LinkForty/mcp-server) · npm: [`@linkforty/mcp-server`](https://www.npmjs.com/package/@linkforty/mcp-server) #### What is MCP? The [Model Context Protocol](https://modelcontextprotocol.io) (MCP) is an open standard developed by Anthropic for connecting AI assistants to external tools and data sources. It works like a universal adapter — any MCP-compatible AI client can talk to any MCP server using the same protocol. The **LinkForty MCP server** exposes your workspace to your AI assistant as a set of 20 tools. When you ask Claude something like *"create a deep link for my Spring sale"*, the AI calls the `create_link` tool, which makes an authenticated HTTP request to the LinkForty API and returns the result. The server supports two connection modes: - **HTTP transport** (recommended) — connect via a URL. No local installation, no Node.js required. Works like any other hosted MCP server (Linear, Google, etc.). - **stdio transport** — runs locally on your machine as a subprocess of your AI client. Requires Node.js 18+. #### Why use it Once connected, you can ask your AI assistant to do things like: - *"Create a LinkForty deep link for my Spring sale on Instagram with UTM source=instagram, medium=social, campaign=spring-sale"* - *"Which links drove the most installs last week?"* - *"Show me click trends for my top 5 links over the last 30 days"* - *"Why is link xyz123 getting clicks but no installs?"* - *"Help me add the LinkForty SDK to my React Native app"* - *"Create 50 deep links from this CSV"* - *"What's my install attribution rate this month?"* The AI calls the appropriate LinkForty tools behind the scenes and returns structured results — no manual API requests, no copy-pasting JSON, no leaving your editor or chat window. #### Supported AI clients | Client | HTTP transport | stdio transport | Project-level config | |--------|---------------|----------------|---------------------| | Claude Desktop | ✅ | ✅ | ❌ (global only) | | Cursor | ✅ | ✅ | ✅ (`.cursor/mcp.json`) | | Claude Code | ✅ | ✅ | ✅ (`.mcp.json`) | | Any other MCP-compatible client | ✅ | ✅ | Depends on the client | #### Quick start ##### 1. Get your API key Sign in to your [LinkForty dashboard](https://app.linkforty.com), go to **Workspace Settings → API Keys**, and create a new key. Copy the value (it starts with `dl_`). **Info: One key, one workspace** LinkForty API keys are scoped to a single workspace. If you manage multiple workspaces, create one key per workspace and run multiple MCP server instances if you need them all available simultaneously. ##### 2. Add the MCP server to your AI client Pick your client and transport mode. Replace `dl_your_api_key_here` with your actual API key. ###### HTTP transport (recommended) No local installation required — just a URL and your API key. This is the same setup pattern used by Linear, Google, and other hosted MCP servers. Claude Desktop Edit your `claude_desktop_config.json` file: - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` ```json { "mcpServers": { "linkforty": { "type": "http", "url": "https://mcp.linkforty.com/mcp", "headers": { "Authorization": "Bearer dl_your_api_key_here" } } } } ``` Restart Claude Desktop. The 20 LinkForty tools will appear in the tool picker. Cursor Open **Cursor → Settings → MCP Servers** and add: ```json { "linkforty": { "type": "http", "url": "https://mcp.linkforty.com/mcp", "headers": { "Authorization": "Bearer dl_your_api_key_here" } } } ``` Claude Code Run from your terminal: ```bash claude mcp add linkforty \ --transport http \ --url https://mcp.linkforty.com/mcp \ --header "Authorization: Bearer dl_your_api_key_here" ``` ###### stdio transport (local) Runs the MCP server as a local Node.js subprocess. Requires Node.js 18+. Claude Desktop Edit your `claude_desktop_config.json` file: - **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` - **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` ```json { "mcpServers": { "linkforty": { "command": "npx", "args": ["-y", "@linkforty/mcp-server"], "env": { "LINKFORTY_API_KEY": "dl_your_api_key_here" } } } } ``` Restart Claude Desktop. The 20 LinkForty tools will appear in the tool picker. Cursor Open **Cursor → Settings → MCP Servers** and add: ```json { "linkforty": { "command": "npx", "args": ["-y", "@linkforty/mcp-server"], "env": { "LINKFORTY_API_KEY": "dl_your_api_key_here" } } } ``` Claude Code Run from your terminal: ```bash claude mcp add linkforty \ -e LINKFORTY_API_KEY=dl_your_api_key_here \ -- npx -y @linkforty/mcp-server ``` ##### 3. Try it Open a new chat and ask your AI assistant something like: > Show me my top 5 links from the last 30 days and tell me which countries they're getting traffic from. You should see the AI call `get_overview_analytics` (or `get_top_links`) and return real data from your workspace. #### Project-level setup (advanced) For Cursor and Claude Code, you can scope the MCP server to a single project instead of running it globally. This is useful when: - You only use LinkForty inside one specific app's repository - Your team wants every developer to have the same MCP setup automatically (commit the config to git) - You manage multiple LinkForty workspaces and want different repos to use different ones **Warning: Don't commit your API key** The config snippets below reference an environment variable instead of hardcoding the key, so the file is safe to check into git. Set `LINKFORTY_API_KEY` in your shell, `.envrc`, or a gitignored `.env.local` file. ##### Claude Code (`.mcp.json`) Create a `.mcp.json` file in your project root. Claude Code will pick it up automatically when you open the directory. **HTTP transport:** ```json { "mcpServers": { "linkforty": { "type": "http", "url": "https://mcp.linkforty.com/mcp", "headers": { "Authorization": "Bearer ${LINKFORTY_API_KEY}" } } } } ``` **stdio transport:** ```json { "mcpServers": { "linkforty": { "command": "npx", "args": ["-y", "@linkforty/mcp-server"], "env": { "LINKFORTY_API_KEY": "${LINKFORTY_API_KEY}" } } } } ``` ##### Cursor (`.cursor/mcp.json`) Create a `.cursor/mcp.json` file in your project root. Cursor will load it whenever the project is open. **HTTP transport:** ```json { "mcpServers": { "linkforty": { "type": "http", "url": "https://mcp.linkforty.com/mcp", "headers": { "Authorization": "Bearer ${LINKFORTY_API_KEY}" } } } } ``` **stdio transport:** ```json { "mcpServers": { "linkforty": { "command": "npx", "args": ["-y", "@linkforty/mcp-server"], "env": { "LINKFORTY_API_KEY": "${LINKFORTY_API_KEY}" } } } } ``` ##### Setting the environment variable locally ```bash ### Add to your shell profile (~/.zshrc, ~/.bashrc, etc.) export LINKFORTY_API_KEY=dl_your_api_key_here ### Or with direnv (recommended for project-scoped secrets): echo 'export LINKFORTY_API_KEY=dl_your_api_key_here' >> .envrc direnv allow ### Or use a gitignored .env.local file your shell loads on cd ``` #### Available tools The LinkForty MCP server exposes **20 tools** across 6 categories: ##### Links (6 tools) | Tool | Description | |------|-------------| | `create_link` | Create a new deep link with iOS/Android/web URLs, UTM, targeting, and more | | `list_links` | List/filter links by status, source, project, search query, or date range | | `get_link` | Get a single link by ID or short code | | `update_link` | Update any field on an existing link | | `delete_link` | Permanently delete a link | | `bulk_create_links` | Create up to 100 links in one call | ##### Analytics (5 tools) | Tool | Description | |------|-------------| | `get_link_analytics` | Per-link analytics: clicks, geo, devices, time series | | `get_overview_analytics` | Workspace-wide analytics across all links | | `get_top_links` | Top performing links ranked by clicks | | `get_install_attribution` | Install attribution data with fingerprint matching | | `get_funnel` | Click → install → event conversion funnel | ##### Templates (3 tools) | Tool | Description | |------|-------------| | `list_templates` | List all link templates | | `create_template` | Create a new template with default destinations and UTM | | `set_default_template` | Mark a template as the workspace default | ##### Projects (2 tools) | Tool | Description | |------|-------------| | `list_projects` | List all projects in the workspace | | `create_project` | Create a new project for grouping links | ##### Workspace (3 tools) | Tool | Description | |------|-------------| | `list_workspaces` | List workspaces accessible by the API key | | `get_current_workspace` | Get details about the current workspace | | `get_app_config` | Get iOS/Android bundle IDs, schemes, link domains | ##### SDK helper (1 tool) | Tool | Description | |------|-------------| | `get_sdk_install_snippet` | Generate ready-to-paste SDK init code for any platform (react-native, expo, ios, android, flutter), pre-filled with your API key and app config | #### Configuration reference ##### HTTP transport With the HTTP transport, there are no environment variables to set on the client side. Your API key is passed in the `Authorization` header. If you're **self-hosting the MCP HTTP server**, the following environment variables apply to the server process: | Environment variable | Required | Default | Description | |---------------------|----------|---------|-------------| | `PORT` | No | `3001` | HTTP server port | | `LINKFORTY_BASE_URL` | No | `https://api.linkforty.com/api` | Override for self-hosted [LinkForty Core](https://docs.linkforty.com/getting-started/self-hosting/) instances | ##### stdio transport | Environment variable | Required | Default | Description | |---------------------|----------|---------|-------------| | `LINKFORTY_API_KEY` | Yes | — | Your LinkForty workspace API key (starts with `dl_`) | | `LINKFORTY_BASE_URL` | No | `https://api.linkforty.com/api` | Override for self-hosted [LinkForty Core](https://docs.linkforty.com/getting-started/self-hosting/) instances | ##### Self-hosted LinkForty Core If you're running [LinkForty Core](https://docs.linkforty.com/getting-started/self-hosting/) on your own infrastructure, point the MCP server at your instance. **HTTP transport** — host the MCP HTTP server yourself and set `LINKFORTY_BASE_URL`: ```bash LINKFORTY_BASE_URL=https://your-instance.com/api PORT=3001 npx -y @linkforty/mcp-server-http ``` Then configure your client to point to your hosted URL instead of `mcp.linkforty.com`. **stdio transport** — set the base URL in the env block: ```json { "mcpServers": { "linkforty": { "command": "npx", "args": ["-y", "@linkforty/mcp-server"], "env": { "LINKFORTY_API_KEY": "dl_your_api_key_here", "LINKFORTY_BASE_URL": "https://your-instance.com/api" } } } } ``` #### Security and privacy The MCP server is designed with strict security boundaries: - **No data is stored or logged by the MCP server.** It's a stateless translator between MCP requests and LinkForty's REST API. Every tool call is a fresh HTTP request. There's no telemetry, no analytics, no third parties. - **API keys inherit your workspace permissions.** The MCP server can only do what your API key has permission to do — there's no privilege escalation. If your role is `viewer`, the AI assistant can only call read-only tools. - **API keys are scoped to a single workspace.** An API key for Workspace A cannot read or modify Workspace B's data, even if you own both. **stdio mode:** The MCP server runs locally as a subprocess of your AI client. Your API key is stored in your local config file and only sent to the LinkForty API endpoint. **HTTP mode:** Your API key is sent in the `Authorization` header to the hosted MCP endpoint (`mcp.linkforty.com`), which forwards it to the LinkForty API. The MCP server does not store, log, or cache API keys. All communication uses HTTPS. #### Troubleshooting ##### "Tool not found" or no LinkForty tools appear - Make sure you restarted your AI client after editing the config file - Check that the JSON in your config file is valid (run it through [jsonlint.com](https://jsonlint.com) if unsure) - Verify that `npx` is on your `PATH` — try running `npx -y @linkforty/mcp-server` directly in a terminal. It should print an error about the missing API key, which means the package can be downloaded and run. ##### "API key authentication failed" or 401 errors - Verify the key is correct and active in your dashboard at **Workspace Settings → API Keys** - Make sure you copied the full key (they start with `dl_` followed by 64 hex characters) - If you regenerated the key, make sure you updated your AI client config with the new value ##### Tools work but return empty data - The API key is bound to one workspace — if you have multiple, make sure you're using the key for the workspace you expect - Check that your workspace actually has data (links, clicks, installs). New workspaces will return empty arrays. ##### "Cannot find module" or npm download errors - Make sure you have Node.js 18 or later installed: `node --version` - Try clearing the npx cache: `npx clear-npx-cache` - Try installing globally as a workaround: `npm install -g @linkforty/mcp-server`, then change `command` to `linkforty-mcp` in your config #### Source and contributions - **GitHub**: [github.com/LinkForty/mcp-server](https://github.com/LinkForty/mcp-server) - **npm**: [`@linkforty/mcp-server`](https://www.npmjs.com/package/@linkforty/mcp-server) - **License**: MIT - **Issues**: [github.com/LinkForty/mcp-server/issues](https://github.com/LinkForty/mcp-server/issues) The MCP server is open source. Pull requests welcome — especially for additional tools, support for more AI clients, or quality-of-life improvements. #### Related - [API Authentication](https://docs.linkforty.com/api/authentication/) — How API keys work in LinkForty - [Webhooks](https://docs.linkforty.com/guides/advanced/webhooks/) — Real-time event delivery to your own server - [Analytics Integrations](https://docs.linkforty.com/guides/advanced/integrations/) — Forward events to Mixpanel, Amplitude, GA4, and more - [Model Context Protocol](https://modelcontextprotocol.io) — The open standard the MCP server is built on --- ## Link Safety Source: https://docs.linkforty.com/guides/advanced/link-safety/ **Info: Cloud Feature** Abuse review, workspace restriction, and appeals are Cloud-only. Self-hosted Core deployments include the underlying link states but none of the enforcement or review process described here. See [Cloud vs Self-Hosted](https://docs.linkforty.com/getting-started/cloud-vs-self-hosted/) for details. Short links are useful because they hide their destination. That is also why they attract abuse, and why every link shortener has to take it seriously. A shared link domain carries one reputation for everyone on it. When a phishing campaign runs on `go.linkforty.com`, mail providers and browsers do not distinguish that campaign from your product launch — they see the domain. So enforcement is not only about the customer being abused; it protects every other workspace's deliverability. This page explains what enforcement looks like from the outside, and what to do if it lands on you. **Tip: Your own domain, your own reputation** A [custom domain](https://docs.linkforty.com/guides/links/custom-domains/) carries only your reputation. It is the single best protection against someone else's behaviour affecting your links. #### Three States a Link Can Be In A link that exists is in one of three states: | State | What a visitor sees | |---|---| | **Allow** | The normal redirect. Nothing changes. | | **Warn** | An interstitial page showing the full destination, with a **Continue anyway** link they must click | | **Block** | A `404`, identical to a short code that was never created | **Warn is deliberately not a block.** It applies to a link that is *suspected* rather than confirmed. A false positive still lets the visitor through, while a genuine malicious link loses the one-click flow it depends on. The interstitial also offers a way to report the link. **The states have a fixed precedence.** A restricted owner outranks everything, then an inactive link, then a warning. A link whose owner is restricted is unreachable even if it was only flagged to warn. ##### Why a Blocked Link Looks Like It Never Existed This is intentional and worth understanding before you file a bug about it. A distinct "this link was disabled" page would confirm to whoever is probing that the code was real, and would leak that its owner is under a restriction. Both are useful to an abuser and to nobody else. So a blocked link returns exactly what an unknown code returns. The same is true for [expired links](https://docs.linkforty.com/guides/links/creating-links/#link-settings) — an expired link is inactive, and inactive means blocked. #### Destinations Are Checked When You Save Some destinations are not permitted. When you save a link pointing at one, the link is not saved and the response names the URL that was refused. This applies whenever a destination is written — creating a link or editing an existing one — and on every route: the dashboard, the API, the MCP server, templates, and links your app creates through the SDK. If a destination you control is refused and you believe that is wrong, contact support with the URL. Do not work around it by encoding it differently or redirecting through another host; that reads as evasion and will be treated as such. #### Workspace Restriction When a workspace is restricted, its links stop resolving and it is queued for human review. Restriction is **reversible**. It is a hold pending review, not a deletion, and nothing is destroyed while it is in place. ##### What You Can Still Do A restricted workspace is not locked out. You can still sign in — deliberately, because the restriction notice, your data, and the appeal form are only reachable from inside a session. - **Read and export all of your data.** Analytics and link history remain available to export for as long as the restriction lasts. - **Read the statement of reasons.** The notice names what was actioned rather than saying "contact support", so you can recognise the cause. - **Appeal**, and have a person read it. - **Use your other workspaces.** Restriction applies to one workspace. If you belong to others, they are unaffected and the notice links you to them. ##### Why a Legitimate Workspace Gets Restricted The most common cause is not malice. It is a destination that stopped being yours: - A page on your site was compromised and now serves something else. - A destination host expired and was re-registered by someone else. - A typo'd or copy-pasted URL points somewhere you never intended. - A shortened link from another service was used as a destination, and *its* target changed. This is exactly why the notice names the specific destination. Check that URL first — in a large fraction of cases the finding is correct and something on your side has changed without you noticing. #### Appealing The appeal form is on the restriction notice itself. Describe why you believe the restriction is wrong; that is the only required field. If you are signed in, we already know which workspace you are writing about. A person reviews every appeal. Include anything that helps: - What the destination is, and what it is for - What changed, if a destination was compromised and you have since fixed it - Any context about your business that would not be obvious from the link alone Appeals are rate-limited. Submitting the same appeal repeatedly does not move it up the queue. #### Reporting a Link If you have received a LinkForty link that looks like phishing, malware, or spam, report it at **[app.linkforty.com/abuse](https://app.linkforty.com/abuse)**. No account is needed. You will be asked for: | Field | Required | |---|---| | The URL | Yes | | A category — phishing, malware, spam, IP infringement, or other | Defaults to *other* | | Any detail you can give | Optional | | Your email, if you want a reply | Optional | Reports are rate-limited per network address, generously — a duplicate report is much better than a dropped one. The warning interstitial also carries a report link, so a link that made you suspicious can be reported from the page itself. #### If Your Link Stopped Working Work through these in order: 1. **Check whether it expired.** An expiration date makes a link behave exactly like a blocked one. See [Link Settings](https://docs.linkforty.com/guides/links/creating-links/#link-settings). 2. **Check whether the link is still active** in your dashboard. 3. **Check the destination.** Open it directly. If it redirects somewhere unexpected, that is very likely the cause. 4. **Check for a restriction notice.** Sign in — if the workspace is restricted, you will land on the notice with the specific finding. 5. **Appeal or contact support**, depending on which of the above applies. #### Related - [Custom Domains](https://docs.linkforty.com/guides/links/custom-domains/) — reputation you control - [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) — expiration and other per-link settings - [Shared Links](https://docs.linkforty.com/guides/links/shared-links/) — links your app users create through the SDK --- # API Reference ## API Authentication - LinkForty REST API Source: https://docs.linkforty.com/api/authentication/ LinkForty Core and LinkForty Cloud use different authentication mechanisms. This page covers both approaches. **Info: Core vs Cloud** **LinkForty Core** (self-hosted) uses a simple `userId` query parameter for identifying users. There is no built-in authentication layer — you are expected to handle auth in your own application and pass the `userId` to Core. **LinkForty Cloud** uses API keys (Bearer tokens) for all API requests, with organization-scoped access control. --- #### Core Authentication (Self-Hosted) Core does not include an authentication middleware. Instead, each API request requires a `userId` query parameter (for GET, PUT, DELETE) or a `userId` field in the request body (for POST). Your application is responsible for authenticating users and passing the correct `userId` to Core. ##### Base URL ``` https://your-domain.com ``` ##### How It Works **For GET, PUT, DELETE requests** — pass `userId` as a query parameter: ```bash curl https://your-domain.com/api/links?userId=550e8400-e29b-41d4-a716-446655440000 ``` **For POST requests** — include `userId` in the request body: ```bash curl -X POST https://your-domain.com/api/links \ -H "Content-Type: application/json" \ -d '{ "userId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/product/123" }' ``` ##### Integration Example Since Core has no built-in auth, you typically place it behind your own API gateway or backend that handles user authentication: ```javascript // Your backend proxies requests to Core with the authenticated userId app.post('/api/links', authenticateUser, async (req, res) => { const response = await fetch('http://localhost:3000/api/links', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: req.user.id, // From your auth layer originalUrl: req.body.originalUrl, title: req.body.title }) }); const link = await response.json(); res.json(link); }); ``` ##### Security Considerations Since Core does not enforce authentication, you should: - **Never expose Core directly to the public internet** without an auth layer in front of it - Use a reverse proxy (Nginx, Caddy) that forwards only authenticated requests - Restrict network access to Core's API port (firewall rules) - Use the `CORS_ORIGIN` environment variable to restrict which origins can call the API --- #### Cloud Authentication LinkForty Cloud uses API keys for authentication. All API requests must include a valid API key in the Authorization header. ##### Base URL ``` https://api.linkforty.com ``` ##### Authentication Method **Header Format:** ``` Authorization: Bearer YOUR_API_KEY_HERE ``` ##### What API Keys Can Access {#what-api-keys-can-access} API keys are scoped to LinkForty's programmatic resources: | Resource | Endpoints | |----------|-----------| | **Links** | `/api/links` | | **Analytics & events** | `/api/analytics/*` | | **Projects** | `/api/projects` | | **Templates** | `/api/templates` | | **SDK & pixel ingestion** | `/api/sdk/v1/*`, `/api/pixel/v1/*` (authenticated by app token / site key, not the API key) | Account, workspace, team, and billing management is performed in the LinkForty **dashboard** and is not part of the API. This keeps API keys narrowly scoped to link, analytics, template, and project automation. ##### Getting Your API Key **Via Dashboard:** 1. Log in to your LinkForty dashboard 2. Navigate to **Settings**, then **API Keys** 3. Click **Create API Key** 4. Enter a descriptive name (e.g., "Production Server", "CI/CD Pipeline") 5. Click **Generate** 6. Copy and save the key immediately — it will not be shown again API keys are created and managed in the dashboard (**Settings → API Keys**); the key-management endpoints require a logged-in session and cannot be called with an API key. When you generate a key, the response includes the secret once: ```json { "id": "key_abc123xyz", "name": "New Production Key", "key": "dl_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", "createdAt": "2024-01-15T10:30:00Z", "lastUsedAt": null } ``` Save the `key` value immediately. It cannot be retrieved later. ##### API Key Format LinkForty API keys are a `dl_` prefix followed by 64 hexadecimal characters: ``` dl_{64_hex_characters} ``` **Example:** ``` dl_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2 ``` --- #### Making Authenticated Requests ##### Core (Self-Hosted) ```bash ### List links for a user curl https://your-domain.com/api/links?userId=550e8400-e29b-41d4-a716-446655440000 ### Create a link curl -X POST https://your-domain.com/api/links \ -H "Content-Type: application/json" \ -d '{ "userId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/product/123", "title": "Product Link" }' ``` ##### Cloud (cURL) ```bash curl -X GET https://api.linkforty.com/api/links \ -H "Authorization: Bearer dl_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" ``` ##### Cloud (JavaScript) ```javascript const API_KEY = process.env.LINKFORTY_API_KEY; const response = await fetch('https://api.linkforty.com/api/links', { method: 'GET', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' } }); const links = await response.json(); ``` ##### Cloud (Node.js with axios) ```javascript const axios = require('axios'); const api = axios.create({ baseURL: 'https://api.linkforty.com', headers: { 'Authorization': `Bearer ${process.env.LINKFORTY_API_KEY}`, 'Content-Type': 'application/json' } }); // Get all links const { data: links } = await api.get('/api/links'); // Create a link const { data: newLink } = await api.post('/api/links', { templateId: 'template_123', originalUrl: 'https://example.com/product' }); ``` ##### Cloud (Python) ```python import requests import os API_KEY = os.environ.get('LINKFORTY_API_KEY') BASE_URL = 'https://api.linkforty.com' headers = { 'Authorization': f'Bearer {API_KEY}', 'Content-Type': 'application/json' } ### Get all links response = requests.get(f'{BASE_URL}/api/links', headers=headers) links = response.json() ### Create a link response = requests.post( f'{BASE_URL}/api/links', headers=headers, json={ 'templateId': 'template_123', 'originalUrl': 'https://example.com/product' } ) new_link = response.json() ``` ##### Cloud (Go) ```go package main import ( "bytes" "encoding/json" "fmt" "net/http" "os" ) const baseURL = "https://api.linkforty.com" func main() { apiKey := os.Getenv("LINKFORTY_API_KEY") client := &http.Client{} // Get all links req, _ := http.NewRequest("GET", baseURL+"/api/links", nil) req.Header.Set("Authorization", "Bearer "+apiKey) resp, _ := client.Do(req) defer resp.Body.Close() var links []map[string]interface{} json.NewDecoder(resp.Body).Decode(&links) fmt.Println(links) } ``` ##### Cloud (Ruby) ```ruby require 'net/http' require 'json' API_KEY = ENV['LINKFORTY_API_KEY'] BASE_URL = 'https://api.linkforty.com' ### Get all links uri = URI("#{BASE_URL}/api/links") request = Net::HTTP::Get.new(uri) request['Authorization'] = "Bearer #{API_KEY}" request['Content-Type'] = 'application/json' response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http| http.request(request) end links = JSON.parse(response.body) puts links ``` --- #### Security Best Practices ##### 1. Never Hardcode API Keys **Bad:** ```javascript const API_KEY = 'dl_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2'; ``` **Good:** ```javascript const API_KEY = process.env.LINKFORTY_API_KEY; ``` ##### 2. Use Environment Variables **`.env` file:** ```bash LINKFORTY_API_KEY=dl_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2 ``` **Load in your app:** ```javascript // Node.js (with dotenv) require('dotenv').config(); const API_KEY = process.env.LINKFORTY_API_KEY; // Python (with python-dotenv) from dotenv import load_dotenv import os load_dotenv() API_KEY = os.environ.get('LINKFORTY_API_KEY') ``` **Add to `.gitignore`:** ```bash .env .env.local ``` ##### 3. Use Separate Keys for Different Environments Create different API keys for: - Production server - Staging server - Development/local - CI/CD pipelines - Each team member ##### 4. Rotate Keys Regularly **Recommended:** Rotate API keys every 90 days. **Steps:** 1. Create new API key 2. Update environment variables in all systems 3. Test new key works 4. Delete old key ##### 5. Restrict Key Permissions (Coming Soon) Future feature: Scope keys to specific actions. ```json { "name": "Read-Only Analytics Key", "permissions": ["analytics:read"], "rateLimit": 100 } ``` ##### 6. Monitor Key Usage Check when keys were last used in the dashboard under **Settings → API Keys** — the "Last Used" column shows recent activity, and you can delete unused keys there. The dashboard shows each key with its prefix and last-used timestamp: ```json { "apiKeys": [ { "id": "key_abc123", "name": "Production API Key", "keyPrefix": "dl_a1b2c3d4****", "createdAt": "2024-01-15T10:30:00Z", "lastUsedAt": "2024-01-20T14:25:00Z" } ] } ``` --- #### Error Responses ##### Missing Authentication (Cloud) **Request:** ```bash curl -X GET https://api.linkforty.com/api/links ### No Authorization header ``` **Response:** `401 Unauthorized` ```json { "error": "Unauthorized", "message": "Missing or invalid API key", "statusCode": 401 } ``` ##### Invalid API Key (Cloud) **Response:** `401 Unauthorized` ```json { "error": "Unauthorized", "message": "Invalid API key", "statusCode": 401 } ``` ##### Deleted or Revoked Key (Cloud) **Response:** `401 Unauthorized` ```json { "error": "Unauthorized", "message": "API key has been deleted or revoked", "statusCode": 401 } ``` ##### Wrong Organization (Cloud) **Response:** `403 Forbidden` ```json { "error": "Forbidden", "message": "You do not have access to this resource", "statusCode": 403 } ``` ##### Missing userId (Core) **Request:** ```bash curl https://your-domain.com/api/links ### No userId query parameter ``` **Response:** `400 Bad Request` ```json { "error": "Bad Request", "message": "userId query parameter is required", "statusCode": 400 } ``` --- #### Managing API Keys (Cloud Only) API keys are managed in the dashboard under **Settings → API Keys** (these endpoints require a logged-in session and cannot be called with an API key): - **Create** — click **Create API Key**, name it, and copy the secret once (it can't be retrieved later). - **List** — view all keys with their prefix and last-used time. - **Rename** — update a key's descriptive name. - **Delete** — revoke a key. Deletion is immediate; all requests using that key fail instantly. --- #### Testing Your Setup ##### Core (Self-Hosted) ```bash ### Health check (no auth required) curl https://your-domain.com/health ### Create a test link curl -X POST https://your-domain.com/api/links \ -H "Content-Type: application/json" \ -d '{ "userId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com", "title": "Test Link" }' ### List links curl "https://your-domain.com/api/links?userId=550e8400-e29b-41d4-a716-446655440000" ``` ##### Cloud ```bash ### Health check curl -X GET https://api.linkforty.com/health ### Test API key (lists your links — returns 200 with a valid key) curl -X GET https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` --- #### Rate Limiting See [Rate Limits](https://docs.linkforty.com/api/rate-limits/) for details on API rate limiting. **Summary:** - Core: Rate limiting disabled by default (configurable via environment variables) - Cloud: 100 requests/minute per API key (default) --- #### Troubleshooting ##### "Missing or invalid API key" (Cloud) **Cause:** Authorization header not included or malformed. **Solutions:** 1. Verify header format: `Authorization: Bearer YOUR_API_KEY` 2. Check for typos in "Authorization" or "Bearer" 3. Ensure API key is complete (starts with `dl_`) ##### "API key has been deleted or revoked" (Cloud) **Cause:** The API key was deleted from the dashboard. **Solution:** 1. Create new API key in dashboard 2. Update environment variables 3. Restart application ##### "You do not have access to this resource" (Cloud) **Cause:** API key belongs to different organization than resource. **Solution:** 1. Verify you're using correct API key 2. Check resource ID belongs to your organization 3. Ensure you have proper role (Owner/Admin) ##### "userId query parameter is required" (Core) **Cause:** Missing `userId` in query string or request body. **Solution:** 1. For GET/PUT/DELETE: add `?userId=YOUR_UUID` to the URL 2. For POST: include `userId` in the JSON request body 3. Ensure userId is a valid UUID ##### Requests Work Locally But Fail in Production **Cause:** Environment variable not set in production. **Solution:** 1. Verify `LINKFORTY_API_KEY` is set in production environment (Cloud) 2. Verify Core is accessible from your application server (self-hosted) 3. Check firewall rules and CORS configuration --- #### Next Steps - [Rate Limits](https://docs.linkforty.com/api/rate-limits/) - Understand API rate limiting - [Error Handling](https://docs.linkforty.com/api/errors/) - Handle errors gracefully - [Create Link API](https://docs.linkforty.com/api/endpoints/links/create-link/) - Start creating links - [Analytics API](https://docs.linkforty.com/api/endpoints/analytics/link-analytics/) - Access analytics data --- ## Rate Limits Source: https://docs.linkforty.com/api/rate-limits/ LinkForty API can implement rate limiting to ensure fair usage and protect infrastructure from abuse. #### Overview **Info: Configuration Required** Rate limiting is **disabled by default** in self-hosted deployments. To enable it, set `RATE_LIMIT_ENABLED=true` in your environment variables. LinkForty Cloud (hosted service) has rate limiting enabled by default. **Default Rate Limits (self-hosted, when enabled):** - **100 requests/minute** per API key or IP address - Limits reset every 60 seconds - Rate limit tracking uses Redis for distributed environments **Note: Self-Hosted vs Cloud** The default rate limits documented on this page (100 requests/minute, endpoint-specific limits) apply to **self-hosted** deployments. **Cloud** uses per-plan, organization-level rate limits instead — see [Cloud Plans](#cloud-plans) below. **No request limits on:** - Short link redirects (`/:shortCode`) - Public endpoints (health check, QR codes) #### Rate Limit Headers When rate limiting is enabled, every API response includes rate limit information: ```http HTTP/1.1 200 OK X-RateLimit-Limit: 100 X-RateLimit-Remaining: 95 X-RateLimit-Reset: 1711373520 ``` | Header | Description | Example | |--------|-------------|---------| | `X-RateLimit-Limit` | Maximum requests per window | 100 | | `X-RateLimit-Remaining` | Requests remaining in current window | 95 | | `X-RateLimit-Reset` | Unix timestamp when limit resets (seconds) | 1711373520 | **Note:** These headers are only present when `RATE_LIMIT_ENABLED=true`. If rate limiting is disabled, these headers will not appear in responses. #### Exceeding Rate Limits When you exceed the rate limit: **Status Code:** `429 Too Many Requests` ```json { "error": "Too Many Requests", "message": "Rate limit exceeded. Try again in 42 seconds.", "statusCode": 429, "retryAfter": 42 } ``` **Response Headers:** ```http HTTP/1.1 429 Too Many Requests X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1642531200 Retry-After: 42 ``` #### Handling Rate Limits ##### Basic Retry Logic ```javascript async function makeRequest(url, options) { const response = await fetch(url, options); if (response.status === 429) { const retryAfter = parseInt(response.headers.get('Retry-After') || '60'); console.log(`Rate limited. Retrying in ${retryAfter} seconds...`); await new Promise(resolve => setTimeout(resolve, retryAfter * 1000)); return makeRequest(url, options); } return response; } ``` ##### Exponential Backoff ```javascript async function makeRequestWithBackoff(url, options, attempt = 1) { const response = await fetch(url, options); if (response.status === 429 && attempt <= 3) { const delay = Math.min(1000 * Math.pow(2, attempt), 32000); console.log(`Rate limited. Waiting ${delay}ms before retry ${attempt}/3`); await new Promise(resolve => setTimeout(resolve, delay)); return makeRequestWithBackoff(url, options, attempt + 1); } return response; } ``` ##### Check Before Sending ```javascript class RateLimitedClient { constructor(apiKey, limit = 100) { this.apiKey = apiKey; this.limit = limit; this.remaining = limit; this.resetTime = Date.now() + 60000; } updateLimits(headers) { this.remaining = parseInt(headers.get('X-RateLimit-Remaining')); this.resetTime = parseInt(headers.get('X-RateLimit-Reset')) * 1000; } async waitIfNeeded() { if (this.remaining <= 0) { const waitTime = this.resetTime - Date.now(); if (waitTime > 0) { console.log(`Rate limit reached. Waiting ${waitTime}ms...`); await new Promise(resolve => setTimeout(resolve, waitTime)); } } } async request(url, options = {}) { await this.waitIfNeeded(); const response = await fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${this.apiKey}` } }); this.updateLimits(response.headers); return response; } } // Usage const client = new RateLimitedClient(API_KEY); const response = await client.request('https://api.linkforty.com/api/links'); ``` #### Rate Limit by Endpoint | Endpoint Category | Rate Limit | Notes | |-------------------|------------|-------| | **Link Creation** | 100/min | Applies to POST /api/links | | **Link Updates** | 100/min | Applies to PUT /api/links/:id | | **Link Reads** | 100/min | Applies to GET /api/links | | **Bulk Operations** | 20/min | Lower limit for bulk endpoints | | **Analytics** | 100/min | GET /api/analytics/* | | **Templates** | 100/min | All template operations | | **Webhooks** | 50/min | Webhook management | | **QR Generation** | 50/min | GET /api/links/:id/qr | #### Bulk Operations Bulk endpoints have stricter limits: **Bulk Create Links:** - **20 requests/minute** - **1,000 links per request** maximum ```bash ### This counts as 1 request curl -X POST https://api.linkforty.com/api/links/bulk \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "links": [ { "templateId": "template_123", "originalUrl": "https://example.com/1" }, { "templateId": "template_123", "originalUrl": "https://example.com/2" }, ... // Up to 1,000 links ] }' ``` **Benefit:** Create 1,000 links with 1 request vs 1,000 separate requests. #### Increasing Rate Limits ##### Cloud Plans **Info:** Cloud rate limits are enforced at the organization level based on your subscription tier. Endpoint-specific rate limits (listed above) apply to self-hosted deployments only. **Free Tier:** - **10 requests/minute** - 1,000 links total - 5,000 clicks/month - 1 API key **Pro Tier:** - **60 requests/minute** - 25,000 links - 100,000 clicks/month - 3 API keys **Business Tier:** - **200 requests/minute** - 100,000 links - 500,000 clicks/month - 10 API keys **Unlimited Tier:** - **1,000 requests/minute** - Unlimited links and clicks - Unlimited API keys - Contact: sales@linkforty.com ##### Self-Hosted **Self-hosted deployments have rate limiting disabled by default.** To enable rate limiting, configure these environment variables in your `.env` file: ```bash ### .env RATE_LIMIT_ENABLED=true # Enable rate limiting RATE_LIMIT_MAX=100 # Maximum requests per window (default: 100) RATE_LIMIT_WINDOW=60000 # Time window in milliseconds (default: 60000 = 1 minute) ``` **Examples:** ```bash ### Standard rate limit: 100 requests per minute RATE_LIMIT_ENABLED=true RATE_LIMIT_MAX=100 RATE_LIMIT_WINDOW=60000 ### Higher limit: 500 requests per minute RATE_LIMIT_ENABLED=true RATE_LIMIT_MAX=500 RATE_LIMIT_WINDOW=60000 ### Stricter limit: 50 requests per 30 seconds RATE_LIMIT_ENABLED=true RATE_LIMIT_MAX=50 RATE_LIMIT_WINDOW=30000 ``` **Requirements:** - Redis must be running and configured (`REDIS_URL` env variable) - Rate limiting uses Redis for distributed tracking across multiple server instances #### Best Practices ##### 1. Use Bulk Endpoints Instead of creating links one by one: **Bad:** ```javascript // 100 requests for (const url of urls) { await fetch('https://api.linkforty.com/api/links', { method: 'POST', body: JSON.stringify({ templateId: 'template_123', originalUrl: url }) }); } ``` **Good:** ```javascript // 1 request await fetch('https://api.linkforty.com/api/links/bulk', { method: 'POST', body: JSON.stringify({ links: urls.map(url => ({ templateId: 'template_123', originalUrl: url })) }) }); ``` ##### 2. Cache Responses Don't fetch the same data repeatedly: ```javascript const cache = new Map(); async function getLink(id) { if (cache.has(id)) { return cache.get(id); } const response = await fetch(`https://api.linkforty.com/api/links/${id}`); const link = await response.json(); cache.set(id, link); return link; } ``` ##### 3. Batch Requests Group multiple operations: ```javascript // Instead of 50 separate requests, wait and batch const pendingCreates = []; function queueLinkCreation(data) { pendingCreates.push(data); if (pendingCreates.length >= 50) { flushBatch(); } } async function flushBatch() { const batch = pendingCreates.splice(0, pendingCreates.length); await fetch('https://api.linkforty.com/api/links/bulk', { method: 'POST', body: JSON.stringify({ links: batch }) }); } // Flush every 5 seconds if there are pending requests setInterval(() => { if (pendingCreates.length > 0) { flushBatch(); } }, 5000); ``` ##### 4. Monitor Rate Limit Headers Track your usage: ```javascript async function monitoredRequest(url, options) { const response = await fetch(url, options); const remaining = response.headers.get('X-RateLimit-Remaining'); const limit = response.headers.get('X-RateLimit-Limit'); console.log(`Rate limit: ${remaining}/${limit} remaining`); if (parseInt(remaining) < 10) { console.warn('Approaching rate limit!'); } return response; } ``` ##### 5. Implement Circuit Breakers Stop sending requests when rate limited: ```javascript class CircuitBreaker { constructor() { this.failures = 0; this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN this.resetTime = null; } async execute(fn) { if (this.state === 'OPEN') { if (Date.now() < this.resetTime) { throw new Error('Circuit breaker is OPEN'); } this.state = 'HALF_OPEN'; } try { const result = await fn(); this.onSuccess(); return result; } catch (error) { this.onFailure(); throw error; } } onSuccess() { this.failures = 0; this.state = 'CLOSED'; } onFailure() { this.failures++; if (this.failures >= 3) { this.state = 'OPEN'; this.resetTime = Date.now() + 60000; // 1 minute } } } const breaker = new CircuitBreaker(); await breaker.execute(() => fetch('https://api.linkforty.com/api/links') ); ``` #### Monitoring Rate Limits ##### Check Response Headers Monitor your rate limit usage by inspecting response headers: ```typescript const response = await fetch('https://api.linkforty.com/api/links', { headers: { 'Authorization': `Bearer ${API_KEY}` } }); const limit = response.headers.get('X-RateLimit-Limit'); const remaining = response.headers.get('X-RateLimit-Remaining'); const reset = response.headers.get('X-RateLimit-Reset'); console.log(`Rate Limit: ${remaining}/${limit} remaining`); console.log(`Resets at: ${new Date(parseInt(reset) * 1000).toISOString()}`); if (parseInt(remaining) < 10) { console.warn('Approaching rate limit!'); } ``` #### Webhook Rate Limits Webhook deliveries have separate limits: **Outbound Webhooks:** - 10 deliveries/second per webhook - 3 retry attempts with exponential backoff - 30-second timeout per delivery If your webhook endpoint is slow or down, deliveries may be delayed or dropped. #### Common Errors ##### Error: "Rate limit exceeded" ```json { "error": "Too Many Requests", "message": "Rate limit exceeded. Try again in 42 seconds.", "statusCode": 429 } ``` **Solution:** - Wait for `retryAfter` seconds - Implement exponential backoff - Use bulk endpoints - Cache responses ##### Error: "Bulk operation limit exceeded" ```json { "error": "Bad Request", "message": "Maximum 1000 links per bulk request", "statusCode": 400 } ``` **Solution:** - Split into multiple batches of 1,000 links each - Add delay between batches (1 request/3 seconds = 20/min limit) #### Self-hosted deployments The limits above apply to LinkForty Cloud. If you self-host the open-source Core, there are no imposed API rate limits — you set whatever your own infrastructure allows. #### Next Steps - [Error Handling](https://docs.linkforty.com/api/errors/) - Handle API errors gracefully - [Authentication](https://docs.linkforty.com/api/authentication/) - API key management - [Create Link API](https://docs.linkforty.com/api/endpoints/links/create-link/) - API reference - [Bulk Operations](https://docs.linkforty.com/api/endpoints/links/bulk-operations/) - Batch link creation #### Support Need higher rate limits? - **Pro Plan**: 60 requests/minute - **Business Plan**: 200 requests/minute - **Unlimited Plan**: 1,000 requests/minute - **Self-Hosted**: No limits by default, deploy on your infrastructure --- ## Error Handling Source: https://docs.linkforty.com/api/errors/ LinkForty API uses conventional HTTP status codes and returns detailed error information in JSON format. #### Error Response Format All errors follow this structure: ```json { "message": "Human-readable description", "error": "STABLE_CODE", "errors": [ { "path": "field", "message": "What is wrong with this field" } ] } ``` | Field | Type | Description | |-------|------|-------------| | `message` | string | Human-readable error message, safe to show to an end user | | `error` | string | Stable, machine-readable code such as `LINK_NOT_FOUND`. Branch on this, not on `message` | | `errors` | array | Present on validation failures only: one entry per field, with its `path` and reason | The HTTP status code is carried by the response itself, not repeated in the body. ##### Stable error codes Codes never change once published. Messages may be reworded. | Code | Status | Meaning | |------|--------|---------| | `VALIDATION_ERROR` | 400 | One or more fields failed validation; see `errors` | | `INVALID_JSON_BODY` | 400 | The request body is not valid JSON | | `NO_UPDATES` | 400 | An update request contained no fields to change | | `INVALID_BULK_PAYLOAD` | 400 | A bulk endpoint received a missing or empty array | | `BULK_LIMIT_EXCEEDED` | 400 | A bulk endpoint received more than 100 items | | `DOMAIN_NOT_VERIFIED` | 400 | The `domainId` is not one of your verified custom domains | | `CURRENT_PASSWORD_INCORRECT` | 400 | Password change rejected because the current password did not match | | `AUTH_REQUIRED`, `UNAUTHORIZED` | 401 | Missing or invalid credential | | `WORKSPACE_RESTRICTED` | 403 | The workspace is restricted; writes are refused | | `LINK_NOT_FOUND`, `TEMPLATE_NOT_FOUND`, `PROJECT_NOT_FOUND`, `API_KEY_NOT_FOUND`, `USER_NOT_FOUND`, `ORGANIZATION_NOT_FOUND` | 404 | The addressed resource does not exist in your workspace | | `EMAIL_TAKEN` | 409 | The requested account email is already in use | | `TEMPLATE_HAS_LINKS` | 409 | A template cannot be deleted while links still use it | #### HTTP Status Codes ##### 2xx Success | Code | Name | Description | |------|------|-------------| | **200** | OK | Request succeeded | | **201** | Created | Resource created successfully | | **204** | No Content | Request succeeded, no response body | ##### 4xx Client Errors | Code | Name | When It Happens | |------|------|-----------------| | **400** | Bad Request | Invalid request format or validation error | | **401** | Unauthorized | Missing or invalid API key | | **403** | Forbidden | Insufficient permissions | | **404** | Not Found | Resource doesn't exist | | **409** | Conflict | Resource already exists (e.g., duplicate short code) | | **422** | Unprocessable Entity | Request valid but semantically incorrect | | **429** | Too Many Requests | Rate limit exceeded | ##### 5xx Server Errors | Code | Name | When It Happens | |------|------|-----------------| | **500** | Internal Server Error | Unexpected server error | | **502** | Bad Gateway | Upstream service unavailable | | **503** | Service Unavailable | Temporary maintenance or overload | | **504** | Gateway Timeout | Request timeout | #### Common Errors ##### 400 Bad Request **Cause:** Invalid request data or validation failure. **Example:** Missing required field ```json { "message": "Validation error", "error": "VALIDATION_ERROR", "errors": [ { "path": "templateId", "message": "Either templateId or templateSlug is required" } ] } ``` **How to fix:** - Check all required fields are present - Verify field formats (URLs, UUIDs, numbers) - Review API documentation for endpoint ##### 401 Unauthorized **Cause:** Missing or invalid API key. **Example:** No Authorization header ```bash curl https://api.linkforty.com/api/links ### No Authorization header provided ``` **Response:** ```json { "error": "Unauthorized", "message": "Missing or invalid API key", "statusCode": 401 } ``` **How to fix:** ```bash curl https://api.linkforty.com/api/links \ -H "Authorization: Bearer dl_a1b2c3d4e5f6a7b8" ``` **Example:** Invalid API key ```json { "error": "Unauthorized", "message": "Invalid API key", "statusCode": 401 } ``` **How to fix:** - Verify API key is correct - Check key hasn't been deleted - Ensure the key belongs to the workspace you're accessing ##### 403 Forbidden **Cause:** Authenticated but lacking permissions. **Example:** Accessing another organization's resource ```json { "error": "Forbidden", "message": "You do not have access to this resource", "statusCode": 403 } ``` **How to fix:** - Verify you're in the correct organization - Check your role (Owner/Admin/Member/Viewer) - Ensure resource belongs to your organization ##### 404 Not Found **Cause:** Resource doesn't exist. **Example:** Link not found ```bash curl https://api.linkforty.com/api/links/nonexistent123 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Response:** ```json { "message": "Link not found", "error": "LINK_NOT_FOUND" } ``` **How to fix:** - Verify resource ID is correct - Check resource hasn't been deleted - Ensure resource exists in your organization ##### 409 Conflict **Cause:** Resource already exists. **Example:** Duplicate custom short code ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "template_123", "originalUrl": "https://example.com", "customCode": "summer-sale" }' ``` **Response:** ```json { "error": "Conflict", "message": "Short code 'summer-sale' is already in use", "statusCode": 409 } ``` **How to fix:** - Use a different custom code - Let LinkForty generate a random code (omit `customCode`) - Check if link was already created ##### 422 Unprocessable Entity **Cause:** Request valid but can't be processed. **Example:** Invalid URL format ```json { "error": "Unprocessable Entity", "message": "Invalid URL format", "statusCode": 422, "validation": { "originalUrl": "Must be a valid URL starting with http:// or https://" } } ``` **How to fix:** - Verify URL format is correct - Ensure URL is reachable - Check for typos ##### 429 Too Many Requests **Cause:** Rate limit exceeded. **Response:** ```json { "error": "Too Many Requests", "message": "Rate limit exceeded. Try again in 42 seconds.", "statusCode": 429, "retryAfter": 42 } ``` **Headers:** ```http X-RateLimit-Limit: 100 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1642531200 Retry-After: 42 ``` **How to fix:** - Wait for `retryAfter` seconds - Implement exponential backoff - Use bulk endpoints - See [Rate Limits](https://docs.linkforty.com/api/rate-limits/) for details ##### 500 Internal Server Error **Cause:** Unexpected server error. **Response:** ```json { "message": "Internal server error" } ``` A 500 never carries detail. If you receive one, the fault is on our side; a 4xx always names what to change in the request. **How to fix:** - Retry the request - If persistent, contact support - Check status page: status.linkforty.com #### Validation Errors Validation errors list every failing field in `errors`. The `path` uses dot notation for nested fields, such as `utmParameters.source`. ```json { "message": "Validation error", "error": "VALIDATION_ERROR", "errors": [ { "path": "webFallbackUrl", "message": "Invalid url" }, { "path": "attributionWindowHours", "message": "Attribution window must be at most 2160 hours (90 days)" } ] } ``` **Common validation errors:** | Field | Error | Fix | |-------|-------|-----| | `originalUrl` | "Must be a valid URL" | Include http:// or https:// | | `templateId` | "Must be a valid UUID" | Use proper template ID format | | `attributionWindowHours` | "Must be between 1 and 2160" | Set value in allowed range | | `customCode` | "Already in use" | Choose different code | | `expiresAt` | "Must be a future date" | Set date in future | #### Error Handling Best Practices ##### 1. Always Check Status Code ```javascript const response = await fetch('https://api.linkforty.com/api/links', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(linkData) }); if (!response.ok) { const error = await response.json(); console.error(`Error ${error.statusCode}: ${error.message}`); throw new Error(error.message); } const link = await response.json(); ``` ##### 2. Handle Specific Errors ```javascript try { const response = await createLink(linkData); return response; } catch (error) { if (error.statusCode === 409) { // Duplicate short code - try with different code return createLink({ ...linkData, customCode: generateRandomCode() }); } else if (error.statusCode === 429) { // Rate limited - wait and retry await sleep(error.retryAfter * 1000); return createLink(linkData); } else if (error.statusCode === 400) { // Validation error - show to user throw new ValidationError(error.validation); } else if (error.statusCode >= 500) { // Server error - retry with backoff return retryWithBackoff(() => createLink(linkData)); } else { // Other error - propagate throw error; } } ``` ##### 3. Implement Retry Logic ```javascript async function retryWithBackoff(fn, maxRetries = 3) { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { if (attempt === maxRetries || error.statusCode < 500) { throw error; } const delay = Math.min(1000 * Math.pow(2, attempt), 32000); console.log(`Retry ${attempt}/${maxRetries} in ${delay}ms`); await sleep(delay); } } } ``` ##### 4. Log Errors Properly ```javascript async function createLink(data) { try { const response = await fetch('https://api.linkforty.com/api/links', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(data) }); if (!response.ok) { const error = await response.json(); logError({ endpoint: '/api/links', method: 'POST', statusCode: error.statusCode, message: error.message, requestData: data, timestamp: new Date().toISOString() }); throw error; } return await response.json(); } catch (error) { throw error; } } ``` ##### 5. User-Friendly Error Messages ```javascript function getUserMessage(error) { switch (error.statusCode) { case 400: return `Invalid input: ${Object.values(error.validation || {}).join(', ')}`; case 401: return 'Authentication failed. Please check your API key.'; case 403: return 'You don\'t have permission to perform this action.'; case 404: return 'The requested resource was not found.'; case 409: return 'This short code is already in use. Please choose another.'; case 429: return `Too many requests. Please wait ${error.retryAfter} seconds.`; case 500: case 502: case 503: case 504: return 'Server error. Please try again later.'; default: return 'An unexpected error occurred. Please try again.'; } } ``` #### Debugging Errors ##### Check Request/Response ```javascript const response = await fetch('https://api.linkforty.com/api/links', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify(linkData) }); console.log('Status:', response.status); console.log('Headers:', Object.fromEntries(response.headers)); const body = await response.text(); console.log('Body:', body); ``` ##### Test with cURL ```bash curl -v -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "templateId": "template_123", "originalUrl": "https://example.com" }' ``` #### Production Error Monitoring ##### 1. Centralized Error Tracking ```javascript import * as Sentry from '@sentry/node'; Sentry.init({ dsn: 'your-sentry-dsn' }); async function createLink(data) { try { return await client.links.create(data); } catch (error) { Sentry.captureException(error, { tags: { endpoint: 'create_link', statusCode: error.statusCode }, extra: { requestData: data } }); throw error; } } ``` ##### 2. Error Metrics ```javascript const errorCounts = { 400: 0, 401: 0, 404: 0, 429: 0, 500: 0 }; function trackError(error) { errorCounts[error.statusCode] = (errorCounts[error.statusCode] || 0) + 1; // Send to monitoring service metrics.increment('linkforty.api.error', { statusCode: error.statusCode, endpoint: error.endpoint }); } ``` ##### 3. Alert on Error Spikes ```javascript if (errorCounts[500] > 10) { alert('High rate of 500 errors from LinkForty API!'); } if (errorCounts[429] > 5) { alert('Hitting rate limits frequently - consider upgrading plan'); } ``` #### Troubleshooting Guide ##### Error: "Invalid API key" **Check:** 1. API key starts with `dl_` 2. Key hasn't been deleted from dashboard 3. Key belongs to the correct workspace **Fix:** - Generate new API key from dashboard - Update environment variables ##### Error: "Template not found" **Check:** 1. Template ID is correct 2. Template belongs to your organization 3. Template hasn't been deleted **Fix:** - List templates: `GET /api/templates` - Create new template if needed - Use correct template ID ##### Error: "Short code already in use" **Check:** 1. Custom code is globally unique 2. Code hasn't been used by your org before **Fix:** - Choose different custom code - Omit `customCode` to auto-generate - Check existing links: `GET /api/links` #### Next Steps - [Authentication](https://docs.linkforty.com/api/authentication/) - API key management - [Rate Limits](https://docs.linkforty.com/api/rate-limits/) - Understand rate limiting - [Create Link API](https://docs.linkforty.com/api/endpoints/links/create-link/) - API reference - [SDK Integration](https://docs.linkforty.com/guides/attribution/sdk-integration/) - Mobile SDK setup #### Support Having trouble with errors? - [API Documentation](https://docs.linkforty.com/api/authentication/) - [Community Forum](https://github.com/linkforty/core/discussions) - [Report Bug](https://github.com/linkforty/core/issues) - Email: inbox@linkforty.com --- # Endpoints: Links ## Create Link Source: https://docs.linkforty.com/api/endpoints/links/create-link/ Create a new short link with platform-specific routing, attribution tracking, and UTM parameters. **Info: Core vs Cloud** **Core** and **Cloud** accept different fields. Core requires `userId` and `originalUrl`. Cloud requires either `templateId` or `templateSlug`, derives `userId` from your API key, and does **not** accept `originalUrl` — its web destination is `webFallbackUrl`. See examples for both below. #### Endpoint ``` POST /api/links ``` #### Authentication - **Core (Self-Hosted):** Include `userId` in the request body. See [Authentication](https://docs.linkforty.com/api/authentication/). - **Cloud:** Requires a valid API key in the Authorization header. See [Authentication](https://docs.linkforty.com/api/authentication/). --- #### Request Body ##### Core Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `userId` | string (UUID) | Yes | User ID for link ownership | | `originalUrl` | string (URL) | Yes | Destination URL (fallback) | | `templateId` | string (UUID) | No | Template to use for default values | | `customCode` | string | No | Custom short code | | `title` | string | No | Internal title | | `description` | string | No | Internal description | | `iosAppStoreUrl` | string (URL) | No | iOS App Store URL | | `androidAppStoreUrl` | string (URL) | No | Android Play Store URL | | `webFallbackUrl` | string (URL) | No | Destination for web users | | `appScheme` | string | No | Custom URL scheme, without `://` (e.g. `myapp`) | | `iosUniversalLink` | string (URL) | No | iOS Universal Link URL | | `androidAppLink` | string (URL) | No | Android App Link URL | | `deepLinkPath` | string | No | Deep link path within the app | | `deepLinkParameters` | object | No | Key-value pairs passed to the app | | `attributionWindowHours` | number | No | Attribution window (1-2160 hours). Default: 168 (7 days) | | `utmParameters` | object | No | UTM parameters to append | | `targetingRules` | object | No | Geographic/language/device targeting | | `ogTitle` | string | No | Open Graph title for social previews | | `ogDescription` | string | No | Open Graph description | | `ogImageUrl` | string (URL) | No | Open Graph image URL | | `ogType` | string | No | Open Graph type (e.g. `website`) | | `expiresAt` | string (ISO 8601) | No | Expiration timestamp | ##### Cloud Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `templateId` | string (UUID) | See note | Template to use for default values | | `templateSlug` | string | See note | Template slug, as an alternative to `templateId` | | `title` | string | No | Internal title for organization | | `description` | string | No | Internal description | | `iosAppStoreUrl` | string (URL) | No | Destination for iOS users | | `androidAppStoreUrl` | string (URL) | No | Destination for Android users | | `webFallbackUrl` | string (URL) | No | Destination for web users (http/https only) | | `appScheme` | string | No | Custom URL scheme, lowercase, without `://` (e.g. `myapp`) | | `iosUniversalLink` | string (URL) | No | iOS Universal Link URL | | `androidAppLink` | string (URL) | No | Android App Link URL | | `deepLinkPath` | string | No | Deep link path within the app | | `customSchemeUrl` | string | No | Full custom-scheme destination (e.g. `myapp://product/123`) | | `deepLinkParameters` | object | No | Key-value pairs passed to the app | | `customCode` | string | No | Custom short code | | `projectId` | string (UUID) \| null | No | Project to assign the link to. `null` clears the assignment | | `domainId` | string (UUID) \| null | No | Custom domain to issue the link on. `null` uses the workspace default | | `utmParameters` | object | No | UTM parameters to append | | `targetingRules` | object | No | Geographic/language/device targeting | | `attributionWindowHours` | number | No | Install attribution window, 1-2160 hours | | `eventAttributionWindowHours` | number | No | In-app event attribution window, 1-2160 hours | | `expiresAt` | string (ISO 8601) | No | Expiration timestamp | | `appendClickId` | boolean | No | Append `?lf_click=` to web destinations. Defaults to `true` on Cloud | | `ogTitle` | string (max 255) | No | Open Graph title for this link's share preview | | `ogDescription` | string (max 500) | No | Open Graph description | | `ogImageUrl` | string (URL) | No | Open Graph image URL, http(s) only | | `ogType` | string | No | Open Graph type. Defaults to `website` | **Tip: Per-link previews for user-shared content** The `og*` fields override the workspace and template share cards for that one link, so a link a user creates to share a specific item can unfurl with that item's own title and image. Leave them unset and the link uses the card configured in the dashboard. See [Social Share Preview](https://docs.linkforty.com/guides/links/social-previews/#how-a-card-is-resolved) for the full resolution order. **Info: Cloud requires a template** Every Cloud link belongs to a template, but you may identify it with **either** `templateId` **or** `templateSlug` — one of the two is required. Templates define default settings for your links. See [Link Templates](https://docs.linkforty.com/guides/links/link-templates/). **Caution: Unknown fields are silently ignored** The request schema discards field names it does not recognise instead of rejecting them, so a misspelled or non-existent field produces a successful response with that value dropped. If a link is created without a destination you set, check the field name against this table first. In particular, Cloud does **not** accept `originalUrl`, `iosUrl`, or `androidUrl`. Those are Core field names — the Cloud equivalents are `webFallbackUrl`, `iosAppStoreUrl`, and `androidAppStoreUrl`. ##### UTM Parameters Object ```typescript { source?: string; // Traffic source (e.g., "instagram") medium?: string; // Marketing medium (e.g., "social") campaign?: string; // Campaign name (e.g., "spring-sale-2024") term?: string; // Paid keywords (e.g., "wireless-headphones") content?: string; // Content variant (e.g., "carousel-ad-1") } ``` ##### Targeting Rules Object ```typescript { countries?: string[]; // ISO 3166-1 alpha-2 codes (e.g., ["US", "CA", "GB"]) languages?: string[]; // ISO 639-1 codes (e.g., ["en", "es", "fr"]) devices?: string[]; // ["ios", "android", "web"] } ``` --- #### Response ##### Success Response **Status Code:** `201 Created` **Core response:** ```json { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "short_code": "abc123", "user_id": "550e8400-e29b-41d4-a716-446655440000", "original_url": "https://example.com/products/wireless-headphones", "title": "Wireless Headphones - Spring Sale", "description": "Q2 2024 Instagram campaign", "ios_app_store_url": "https://apps.apple.com/app/your-store/id123456789", "android_app_store_url": "https://play.google.com/store/apps/details?id=com.yourstore", "web_fallback_url": "https://example.com/products/wireless-headphones?platform=web", "attribution_window_hours": 168, "utm_parameters": { "source": "instagram", "medium": "social", "campaign": "spring-sale-2024" }, "targeting_rules": { "countries": ["US", "CA", "GB"], "languages": ["en"], "devices": ["ios", "android"] }, "is_active": true, "expires_at": null, "created_at": "2024-01-20T15:30:00.000Z", "updated_at": "2024-01-20T15:30:00.000Z" } ``` **Cloud response** includes additional fields: ```json { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "short_code": "abc123", "short_url": "https://lnk.forty.com/abc123", "user_id": "user_123", "organization_id": "org_456", "project_id": null, "template_id": "template_789", "original_url": null, "title": "Wireless Headphones - Spring Sale", "ios_app_store_url": "https://apps.apple.com/app/your-store/id123456789", "android_app_store_url": "https://play.google.com/store/apps/details?id=com.yourstore", "web_fallback_url": "https://example.com/products/wireless-headphones?platform=web", "attribution_window_hours": 168, "utm_parameters": { "source": "instagram", "medium": "social", "campaign": "spring-sale-2024" }, "is_active": true, "expires_at": null, "created_at": "2024-01-20T15:30:00.000Z", "updated_at": "2024-01-20T15:30:00.000Z" } ``` ##### Error Responses ###### 400 Bad Request - Invalid URL ```json { "error": "Bad Request", "message": "Invalid URL format", "statusCode": 400, "validation": { "originalUrl": "Must be a valid URL" } } ``` ###### 400 Bad Request - Invalid Attribution Window ```json { "error": "Bad Request", "message": "Attribution window must be between 1 and 2160 hours", "statusCode": 400, "validation": { "attributionWindowHours": "Attribution window must be at least 1 hour" } } ``` ###### 409 Conflict - Custom Code Already Exists ```json { "error": "Conflict", "message": "Short code 'spring-sale' is already in use", "statusCode": 409 } ``` ###### 401 Unauthorized (Cloud) ```json { "error": "Unauthorized", "message": "Missing or invalid API key", "statusCode": 401 } ``` ###### 404 Not Found - Template Not Found (Cloud) ```json { "error": "Not Found", "message": "Template not found", "statusCode": 404 } ``` --- #### Examples ##### Core - Basic Link Creation ```bash curl -X POST https://your-domain.com/api/links \ -H "Content-Type: application/json" \ -d '{ "userId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/product/123" }' ``` ##### Core - With Platform-Specific URLs ```bash curl -X POST https://your-domain.com/api/links \ -H "Content-Type: application/json" \ -d '{ "userId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/product/123", "title": "Product 123 Campaign", "iosAppStoreUrl": "https://apps.apple.com/app/your-store/id123456789", "androidAppStoreUrl": "https://play.google.com/store/apps/details?id=com.yourstore", "deepLinkPath": "/product/123" }' ``` ##### Core - With Deep Linking ```bash curl -X POST https://your-domain.com/api/links \ -H "Content-Type: application/json" \ -d '{ "userId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/product/123", "appScheme": "myapp://", "iosUniversalLink": "https://myapp.com/product/123", "androidAppLink": "https://myapp.com/product/123", "deepLinkPath": "/product/123", "deepLinkParameters": { "productId": "123", "source": "campaign" } }' ``` ##### Cloud - Basic Link Creation ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer dl_a1b2c3d4e5f6a7b8" \ -H "Content-Type: application/json" \ -d '{ "templateId": "550e8400-e29b-41d4-a716-446655440000", "webFallbackUrl": "https://example.com/product/123" }' ``` ##### Cloud - With Platform-Specific URLs ```bash curl -X POST https://api.linkforty.com/api/links \ -H "Authorization: Bearer dl_a1b2c3d4e5f6a7b8" \ -H "Content-Type: application/json" \ -d '{ "templateId": "550e8400-e29b-41d4-a716-446655440000", "webFallbackUrl": "https://example.com/product/123", "title": "Product 123 Campaign", "iosAppStoreUrl": "https://apps.apple.com/app/your-store/id123456789", "androidAppStoreUrl": "https://play.google.com/store/apps/details?id=com.yourstore" }' ``` ##### With UTM Parameters ```bash curl -X POST https://your-domain.com/api/links \ -H "Content-Type: application/json" \ -d '{ "userId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/product/123", "utmParameters": { "source": "facebook", "medium": "cpc", "campaign": "summer-2024", "term": "wireless-headphones", "content": "image-ad-1" } }' ``` ##### With Attribution Window ```bash curl -X POST https://your-domain.com/api/links \ -H "Content-Type: application/json" \ -d '{ "userId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/product/123", "attributionWindowHours": 336 }' ``` **Attribution Window:** 336 hours = 14 days ##### With Targeting Rules ```bash curl -X POST https://your-domain.com/api/links \ -H "Content-Type: application/json" \ -d '{ "userId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/product/123", "targetingRules": { "countries": ["US", "CA"], "languages": ["en"], "devices": ["ios", "android"] } }' ``` **Behavior:** Only users in US/Canada, with English language settings, on mobile devices will be redirected. Others see default URL. ##### With Expiration ```bash curl -X POST https://your-domain.com/api/links \ -H "Content-Type: application/json" \ -d '{ "userId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/flash-sale", "title": "24-Hour Flash Sale", "expiresAt": "2024-12-31T23:59:59Z" }' ``` --- #### Use Cases ##### E-commerce Product Link ```json { "userId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://shop.example.com/products/wireless-headphones", "title": "Wireless Headphones Product Page", "iosAppStoreUrl": "https://apps.apple.com/app/shop-app/id123", "androidAppStoreUrl": "https://play.google.com/store/apps/details?id=com.shop", "deepLinkPath": "/products/wireless-headphones", "attributionWindowHours": 168, "utmParameters": { "source": "instagram", "medium": "social", "campaign": "product-launch", "content": "product-123" } } ``` **User Flow:** 1. User clicks link on Instagram 2. Mobile user redirected to App Store/Google Play 3. User installs app 4. App opens and navigates to wireless headphones product page 5. Install attributed to Instagram campaign ##### Referral Link ```json { "userId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/referral/john-doe", "attributionWindowHours": 2160, "utmParameters": { "source": "referral", "medium": "friend", "campaign": "refer-a-friend", "content": "john-doe" } } ``` ##### Event Promotion ```json { "userId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/events/summer-concert", "attributionWindowHours": 720, "expiresAt": "2024-07-31T23:59:59Z", "utmParameters": { "source": "facebook", "medium": "event", "campaign": "summer-concert-2024" } } ``` **Expires:** July 31, 2024 (after event ends) --- #### Best Practices ##### 1. Always Provide Platform-Specific URLs **Bad:** ```json { "originalUrl": "https://example.com/product" } ``` **Good (Core):** ```json { "originalUrl": "https://example.com/product", "iosAppStoreUrl": "https://apps.apple.com/app/id123", "androidAppStoreUrl": "https://play.google.com/store/apps/details?id=com.app" } ``` **Good (Cloud):** ```json { "webFallbackUrl": "https://example.com/product", "iosAppStoreUrl": "https://apps.apple.com/app/id123", "androidAppStoreUrl": "https://play.google.com/store/apps/details?id=com.app" } ``` Improves attribution accuracy by 3-5x. ##### 2. Use Descriptive Titles **Bad:** ```json { "title": "Link 1" } ``` **Good:** ```json { "title": "Instagram Story - Spring Sale - Wireless Headphones" } ``` Makes links easier to find and manage. ##### 3. Set Attribution Window Based on Campaign | Campaign Type | Window | |--------------|--------| | Flash sale | 24-72 hours | | Product launch | 7-14 days | | Referral program | 30-90 days | ##### 4. Include UTM Parameters Always track campaign source: ```json { "utmParameters": { "source": "instagram", "medium": "social", "campaign": "spring-2024" } } ``` --- #### Rate Limiting - **Core:** Rate limiting disabled by default (configurable via environment variables) - **Cloud:** 100 requests/minute per API key See [Rate Limits](https://docs.linkforty.com/api/rate-limits/) for details. #### Related Endpoints - [Get Link](https://docs.linkforty.com/api/endpoints/links/get-link/) - Retrieve link details - [Update Link](https://docs.linkforty.com/api/endpoints/links/update-link/) - Modify existing link - [Delete Link](https://docs.linkforty.com/api/endpoints/links/delete-link/) - Remove link - [Bulk Operations](https://docs.linkforty.com/api/endpoints/links/bulk-operations/) - Create multiple links at once - [Link Analytics](https://docs.linkforty.com/api/endpoints/analytics/link-analytics/) - View link performance #### Guides - [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) - Complete guide with examples - [Attribution Windows](https://docs.linkforty.com/guides/attribution/attribution-windows/) - Choosing the right window - [Link Templates](https://docs.linkforty.com/guides/links/link-templates/) - Using templates for defaults (Cloud) - [UTM Parameters](https://docs.linkforty.com/guides/links/utm-parameters/) - Campaign tracking best practices --- ## Get Link Source: https://docs.linkforty.com/api/endpoints/links/get-link/ Retrieve details for a single link or list all links. #### Get Single Link ```http GET /api/links/:id ``` Retrieve detailed information about a specific link, including click count. ##### Authentication - **Core (Self-Hosted):** Pass `userId` as a query parameter. See [Authentication](https://docs.linkforty.com/api/authentication/). - **Cloud:** Requires a valid API key in the Authorization header. See [Authentication](https://docs.linkforty.com/api/authentication/). ##### Path Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `id` | string (UUID) | Yes | Link ID | ##### Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `userId` | string (UUID) | Core only | Required for Core to identify link ownership | ##### Response **Core response:** ```json { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "user_id": "550e8400-e29b-41d4-a716-446655440000", "short_code": "abc123", "original_url": "https://example.com/products/123", "title": "Wireless Headphones Product Page", "description": "Premium noise-cancelling headphones", "ios_app_store_url": "https://apps.apple.com/app/id123456789", "android_app_store_url": "https://play.google.com/store/apps/details?id=com.example.app", "web_fallback_url": null, "utm_parameters": { "source": "instagram", "medium": "social", "campaign": "spring-sale-2024" }, "targeting_rules": { "countries": ["US", "CA"], "devices": ["ios", "android"], "languages": ["en"] }, "attribution_window_hours": 168, "expires_at": null, "is_active": true, "click_count": 1248, "created_at": "2024-01-15T10:30:00Z", "updated_at": "2024-01-20T14:22:00Z" } ``` **Cloud response** is the same snake_case row with workspace fields added (`organization_id`, `project_id`, `template_id`), plus a few camelCase aliases the dashboard uses: ```json { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "user_id": "user_123", "organization_id": "org_456", "project_id": "proj_789", "template_id": "tmpl_abc", "domain_id": null, "short_code": "abc123", "original_url": null, "title": "Wireless Headphones Product Page", "description": "Premium noise-cancelling headphones", "ios_app_store_url": "https://apps.apple.com/app/id123456789", "android_app_store_url": "https://play.google.com/store/apps/details?id=com.example.app", "web_fallback_url": null, "utm_parameters": { "source": "instagram", "medium": "social", "campaign": "spring-sale-2024", "content": "story-swipe-up" }, "targeting_rules": { "countries": ["US", "CA"], "devices": ["ios", "android"], "languages": ["en"] }, "attribution_window_hours": 168, "expires_at": null, "is_active": true, "created_at": "2024-01-15T10:30:00Z", "updated_at": "2024-01-20T14:22:00Z", "clickCount": 1248, "utmParameters": { "source": "instagram", "medium": "social", "campaign": "spring-sale-2024", "content": "story-swipe-up" }, "targetingRules": { "countries": ["US", "CA"], "devices": ["ios", "android"], "languages": ["en"] }, "deepLinkParameters": null, "templateSlug": "instagram-campaign", "projectName": "Q1 Campaigns" } ``` ##### Example Requests **Core:** ```bash curl "https://your-domain.com/api/links/a1b2c3d4-e5f6-7890-abcd-ef1234567890?userId=550e8400-e29b-41d4-a716-446655440000" ``` **Cloud:** ```bash curl https://api.linkforty.com/api/links/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` --- #### List All Links ```http GET /api/links ``` Retrieve all links belonging to a user (Core) or organization (Cloud). ##### Authentication - **Core (Self-Hosted):** Pass `userId` as a query parameter. - **Cloud:** Requires a valid API key in the Authorization header. ##### Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `userId` | string (UUID) | Core only | Required for Core to filter by user | | `projectId` | string (UUID) | No | Filter links by project (Cloud only) | ##### Response Returns an array of link objects with click counts. Passing pagination parameters returns `{ links, total, page, limit }` instead of a bare array. ```json [ { "id": "link_1", "short_code": "abc123", "title": "Product A", "web_fallback_url": "https://example.com/products/a", "created_at": "2024-01-15T10:30:00Z", "clickCount": 1248, "templateSlug": "instagram-campaign" }, { "id": "link_2", "short_code": "def456", "title": "Product B", "web_fallback_url": "https://example.com/products/b", "created_at": "2024-01-16T14:20:00Z", "clickCount": 892, "templateSlug": "facebook-ads" } ] ``` Each object is the full link row; the fields above are an excerpt. ##### Example Requests **Core - Get all links for a user:** ```bash curl "https://your-domain.com/api/links?userId=550e8400-e29b-41d4-a716-446655440000" ``` **Cloud - Get all links:** ```bash curl https://api.linkforty.com/api/links \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Cloud - Filter by project:** ```bash curl "https://api.linkforty.com/api/links?projectId=proj_789" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` ##### Sorting Links are returned in descending order by creation date (newest first). #### Error Responses ##### Link Not Found ```json { "statusCode": 404, "error": "Not Found", "message": "Link not found" } ``` **Possible causes:** - Link ID doesn't exist - Link belongs to different organization - Link was deleted ##### Unauthorized ```json { "statusCode": 401, "error": "Unauthorized", "message": "Authentication required" } ``` **Possible causes:** - Missing API key/bearer token - Invalid API key - Expired session token #### Use Cases ##### 1. Display Link Details in Dashboard ```typescript const link = await fetch('https://api.linkforty.com/api/links/abc123', { headers: { 'Authorization': `Bearer ${apiKey}` } }).then(r => r.json()); console.log(`Link: ${link.shortCode}`); console.log(`Clicks: ${link.clickCount}`); console.log(`Created: ${new Date(link.createdAt).toLocaleDateString()}`); ``` ##### 2. Get Link by Short Code Since the endpoint requires UUID, you'll need to list all links and filter: ```typescript const links = await fetch('https://api.linkforty.com/api/links', { headers: { 'Authorization': `Bearer ${apiKey}` } }).then(r => r.json()); const link = links.find(l => l.shortCode === 'abc123'); ``` ##### 3. Monitor Link Performance ```typescript const link = await getLinkById('link_id'); if (link.clickCount > 1000) { console.log('High-performing link!'); } if (link.expiresAt && new Date(link.expiresAt) < new Date()) { console.log('Link expired - needs renewal'); } ``` ##### 4. Audit Recent Links ```typescript const links = await getAllLinks(); const recentLinks = links.filter(link => { const createdDate = new Date(link.createdAt); const weekAgo = new Date(); weekAgo.setDate(weekAgo.getDate() - 7); return createdDate > weekAgo; }); console.log(`${recentLinks.length} links created in last 7 days`); ``` #### Best Practices ##### 1. Cache Link Data Link details rarely change, so cache them: ```typescript const linkCache = new Map(); async function getLinkWithCache(linkId: string) { if (linkCache.has(linkId)) { return linkCache.get(linkId); } const link = await fetch(`/api/links/${linkId}`, { headers: { 'Authorization': `Bearer ${apiKey}` } }).then(r => r.json()); linkCache.set(linkId, link); return link; } ``` ##### 2. Handle Pagination (Future) While the current API returns all links, plan for pagination: ```typescript // Future-proof for when pagination is added async function getAllLinksWithPagination() { let allLinks = []; let page = 1; let hasMore = true; while (hasMore) { const response = await fetch( `/api/links?page=${page}&limit=100`, { headers: { 'Authorization': `Bearer ${apiKey}` } } ); const data = await response.json(); allLinks = allLinks.concat(data.links || data); hasMore = data.hasMore || false; page++; } return allLinks; } ``` ##### 3. Filter Client-Side For small datasets, filter in your application: ```typescript const links = await getAllLinks(); // Active links only const activeLinks = links.filter(l => l.isActive); // High-performing links const topLinks = links .filter(l => l.clickCount > 100) .sort((a, b) => b.clickCount - a.clickCount); // Links by campaign const campaignLinks = links.filter( l => l.utmParameters?.campaign === 'spring-sale-2024' ); ``` #### Related Endpoints - [Create Link](https://docs.linkforty.com/api/endpoints/links/create-link/) - Create a new link - [Update Link](https://docs.linkforty.com/api/endpoints/links/update-link/) - Modify link properties - [Delete Link](https://docs.linkforty.com/api/endpoints/links/delete-link/) - Remove a link - [Link Analytics](https://docs.linkforty.com/api/endpoints/analytics/link-analytics/) - View click analytics #### Guides - [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) - Complete guide - [Link Templates](https://docs.linkforty.com/guides/links/link-templates/) - Using templates - [UTM Parameters](https://docs.linkforty.com/guides/links/utm-parameters/) - Campaign tracking --- ## Update Link Source: https://docs.linkforty.com/api/endpoints/links/update-link/ Modify an existing link's properties including URLs, UTM parameters, targeting rules, and activation status. #### Endpoint ```http PUT /api/links/:id ``` ##### Authentication - **Core (Self-Hosted):** Pass `userId` as a query parameter. See [Authentication](https://docs.linkforty.com/api/authentication/). - **Cloud:** Requires a valid API key in the Authorization header. See [Authentication](https://docs.linkforty.com/api/authentication/). ##### Path Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `id` | string (UUID) | Yes | Link ID to update | ##### Query Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `userId` | string (UUID) | Core only | Required for Core to verify ownership | ##### Request Body All fields are optional - only include the fields you want to update. | Field | Type | Description | |-------|------|-------------| | `originalUrl` | string (URL) | Default destination URL | | `title` | string | Link title/name | | `description` | string | Link description | | `iosUrl` | string (URL) | iOS-specific redirect URL | | `androidUrl` | string (URL) | Android-specific redirect URL | | `webFallbackUrl` | string (URL) | Web fallback URL | | `utmParameters` | object | UTM tracking parameters | | `utmParameters.source` | string | UTM source | | `utmParameters.medium` | string | UTM medium | | `utmParameters.campaign` | string | UTM campaign | | `utmParameters.term` | string | UTM term | | `utmParameters.content` | string | UTM content | | `targetingRules` | object | Geographic/device targeting | | `targetingRules.countries` | string[] | ISO country codes (e.g., ["US", "CA"]) | | `targetingRules.devices` | string[] | Device types: "ios", "android", "web" | | `targetingRules.languages` | string[] | Language codes (e.g., ["en", "es"]) | | `attributionWindowHours` | number | Hours for install attribution (1-2160) | | `expiresAt` | string (ISO 8601) | Expiration timestamp | | `isActive` | boolean | Whether link is active | | `ogTitle` | string \| null | Open Graph title override. `null` clears it so the share card applies again | | `ogDescription` | string \| null | Open Graph description override | | `ogImageUrl` | string (URL) \| null | Open Graph image URL override, http(s) only | | `ogType` | string \| null | Open Graph type | | `templateId` | string (UUID) | Link template to use | | `projectId` | string (UUID) | Project ID | **Note:** You cannot update the `shortCode` - it's immutable after creation. ##### Response Returns the updated link object. Columns come back in snake_case, with a few camelCase aliases added for convenience: ```json { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "short_code": "abc123", "original_url": null, "title": "Updated: Wireless Headphones", "ios_app_store_url": "https://apps.apple.com/app/id123456789", "android_app_store_url": "https://play.google.com/store/apps/details?id=com.app", "web_fallback_url": "https://example.com/products/new-headphones", "utm_parameters": { "source": "facebook", "medium": "cpc", "campaign": "summer-sale-2024" }, "attribution_window_hours": 336, "is_active": true, "expires_at": null, "updated_at": "2024-01-20T15:30:00.000Z", "utmParameters": { "source": "facebook", "medium": "cpc", "campaign": "summer-sale-2024" }, "targetingRules": null, "deepLinkParameters": null, "domain": null } ``` #### Examples ##### 1. Update Destination URL ```bash curl -X PUT https://api.linkforty.com/api/links/abc123-def456 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "webFallbackUrl": "https://example.com/new-product-page" }' ``` **Use case:** Product link changed to different product. ##### 2. Update UTM Parameters ```bash curl -X PUT https://api.linkforty.com/api/links/abc123-def456 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "utmParameters": { "source": "twitter", "medium": "social", "campaign": "summer-sale-2024", "content": "tweet-1" } }' ``` **Use case:** Change campaign tracking for different channel. ##### 3. Extend Attribution Window ```bash curl -X PUT https://api.linkforty.com/api/links/abc123-def456 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "attributionWindowHours": 720 }' ``` **Use case:** Extend attribution from 7 days (168h) to 30 days (720h) for longer consideration purchases. ##### 4. Update Platform URLs ```bash curl -X PUT https://api.linkforty.com/api/links/abc123-def456 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "iosAppStoreUrl": "https://apps.apple.com/app/id999999", "androidAppStoreUrl": "https://play.google.com/store/apps/details?id=com.newapp" }' ``` **Use case:** App store URLs changed after app relaunch. ##### 5. Deactivate Link ```bash curl -X PUT https://api.linkforty.com/api/links/abc123-def456 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "isActive": false }' ``` **Use case:** Temporarily disable link without deleting (campaign ended). ##### 6. Add Expiration Date ```bash curl -X PUT https://api.linkforty.com/api/links/abc123-def456 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "expiresAt": "2024-12-31T23:59:59Z" }' ``` **Use case:** Set expiration for limited-time offer. ##### 7. Update Multiple Fields ```bash curl -X PUT https://api.linkforty.com/api/links/abc123-def456 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "title": "Black Friday Sale - Wireless Headphones", "webFallbackUrl": "https://example.com/black-friday/headphones", "utmParameters": { "campaign": "black-friday-2024", "content": "hero-banner" }, "expiresAt": "2024-11-30T23:59:59Z" }' ``` **Use case:** Update link for seasonal campaign. ##### 8. Update Targeting Rules ```bash curl -X PUT https://api.linkforty.com/api/links/abc123-def456 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "targetingRules": { "countries": ["US", "CA", "GB"], "devices": ["ios", "android"] } }' ``` **Use case:** Expand campaign to additional countries. #### TypeScript Example ```typescript interface UpdateLinkRequest { originalUrl?: string; title?: string; description?: string; iosUrl?: string; androidUrl?: string; webFallbackUrl?: string; utmParameters?: { source?: string; medium?: string; campaign?: string; term?: string; content?: string; }; targetingRules?: { countries?: string[]; devices?: ('ios' | 'android' | 'web')[]; languages?: string[]; }; attributionWindowHours?: number; expiresAt?: string; isActive?: boolean; templateId?: string; projectId?: string; } async function updateLink(linkId: string, updates: UpdateLinkRequest) { const response = await fetch(`https://api.linkforty.com/api/links/${linkId}`, { method: 'PUT', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify(updates), }); if (!response.ok) { throw new Error(`Failed to update link: ${response.statusText}`); } return response.json(); } // Usage const updated = await updateLink('link_id', { title: 'New Product Launch', utmParameters: { campaign: 'product-launch-2024', }, }); console.log('Updated:', updated.shortCode); ``` #### Common Use Cases ##### 1. Seasonal Campaign Updates Update links when campaigns change: ```typescript // Summer → Fall transition await updateLink(linkId, { title: 'Fall Sale - Wireless Headphones', utmParameters: { campaign: 'fall-sale-2024', }, expiresAt: '2024-11-30T23:59:59Z', }); ``` ##### 2. A/B Test Different Destinations Test different landing pages: ```typescript // Variant A await updateLink('link_a', { originalUrl: 'https://example.com/landing-page-a', utmParameters: { content: 'variant-a' }, }); // Variant B await updateLink('link_b', { originalUrl: 'https://example.com/landing-page-b', utmParameters: { content: 'variant-b' }, }); // After 1 week, check analytics and update losing variant ``` ##### 3. Update App Store URLs After Release When new app version releases: ```typescript await updateLink(linkId, { iosUrl: 'https://apps.apple.com/app/id123456789', androidUrl: 'https://play.google.com/store/apps/details?id=com.app.v2', }); ``` ##### 4. Pause Campaign Temporarily disable without deleting: ```typescript // Pause await updateLink(linkId, { isActive: false }); // Resume later await updateLink(linkId, { isActive: true }); ``` #### Error Responses ##### Link Not Found ```json { "statusCode": 404, "error": "Not Found", "message": "Link not found" } ``` **Possible causes:** - Link ID doesn't exist - Link belongs to different organization ##### No Updates Provided ```json { "statusCode": 400, "error": "Bad Request", "message": "No updates provided" } ``` **Cause:** Empty request body with no fields to update. ##### Invalid URL ```json { "statusCode": 400, "error": "Bad Request", "message": "Validation error: Invalid URL format" } ``` **Cause:** `originalUrl`, `iosUrl`, `androidUrl`, or `webFallbackUrl` is not a valid URL. ##### Invalid Attribution Window ```json { "statusCode": 400, "error": "Bad Request", "message": "Attribution window must be between 1 and 2160 hours" } ``` **Cause:** `attributionWindowHours` outside valid range (1-2160 hours = 1 hour to 90 days). #### Best Practices ##### 1. Update Only What Changed Don't send entire object - only changed fields: **Good:** ```typescript await updateLink(linkId, { title: 'New Title', }); ``` **Bad:** ```typescript // Don't re-send unchanged fields await updateLink(linkId, { title: 'New Title', originalUrl: link.originalUrl, // unchanged iosUrl: link.iosUrl, // unchanged androidUrl: link.androidUrl, // unchanged // etc... }); ``` ##### 2. Validate Before Updating ```typescript function validateUrl(url: string): boolean { try { new URL(url); return true; } catch { return false; } } async function safeUpdateLink(linkId: string, updates: UpdateLinkRequest) { // Validate URLs before sending if (updates.originalUrl && !validateUrl(updates.originalUrl)) { throw new Error('Invalid originalUrl'); } return updateLink(linkId, updates); } ``` ##### 3. Handle Optimistic Updates Update UI immediately, rollback on error: ```typescript async function optimisticUpdate(link: Link, updates: UpdateLinkRequest) { const originalLink = { ...link }; const updatedLink = { ...link, ...updates }; try { // Update UI immediately setLink(updatedLink); // Send to server const result = await updateLink(link.id, updates); // Sync with server response setLink(result); } catch (error) { // Rollback on error setLink(originalLink); showError('Failed to update link'); } } ``` ##### 4. Batch Updates If updating multiple fields, send one request: **Good:** ```typescript await updateLink(linkId, { title: 'New Title', originalUrl: 'https://example.com/new', utmParameters: { campaign: 'new-campaign' }, }); ``` **Bad:** ```typescript await updateLink(linkId, { title: 'New Title' }); await updateLink(linkId, { originalUrl: 'https://example.com/new' }); await updateLink(linkId, { utmParameters: { campaign: 'new-campaign' } }); ``` ##### 5. Track Update History Log updates for audit trail: ```typescript async function updateLinkWithHistory( linkId: string, updates: UpdateLinkRequest, reason: string ) { const updatedLink = await updateLink(linkId, updates); // Log update await logUpdate({ linkId, updates, reason, timestamp: new Date(), updatedBy: currentUser.id, }); return updatedLink; } ``` #### Limitations - **Short code cannot be changed** - It's immutable after creation - **Template change** - Changing `templateId` doesn't retroactively apply template defaults - **Click count** - Cannot be manually modified (read-only) - **Timestamps** - `createdAt` cannot be changed, `updatedAt` is automatic #### Related Endpoints - [Get Link](https://docs.linkforty.com/api/endpoints/links/get-link/) - Retrieve current link details - [Create Link](https://docs.linkforty.com/api/endpoints/links/create-link/) - Create new link - [Delete Link](https://docs.linkforty.com/api/endpoints/links/delete-link/) - Remove link - [Bulk Operations](https://docs.linkforty.com/api/endpoints/links/bulk-operations/) - Update multiple links #### Guides - [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) - Complete guide - [UTM Parameters](https://docs.linkforty.com/guides/links/utm-parameters/) - Campaign tracking - [Link Templates](https://docs.linkforty.com/guides/links/link-templates/) - Using templates --- ## Delete Link Source: https://docs.linkforty.com/api/endpoints/links/delete-link/ Permanently remove a link and all its associated click event data. #### Endpoint ```http DELETE /api/links/:id ``` ##### Authentication - **Core (Self-Hosted):** Pass `userId` as a query parameter. See [Authentication](https://docs.linkforty.com/api/authentication/). - **Cloud:** Requires a valid API key in the Authorization header. See [Authentication](https://docs.linkforty.com/api/authentication/). ##### Path Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `id` | string (UUID) | Yes | Link ID to delete | ##### Response ```json { "success": true } ``` ##### Example Request ```bash curl -X DELETE https://api.linkforty.com/api/links/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` ##### Example Response ```json { "success": true } ``` #### What Gets Deleted When you delete a link, the following data is **permanently removed**: | Data | Deleted? | Notes | |------|----------|-------| | Link record | Yes | Complete link configuration | | Short code | Yes | `abc123` becomes available again | | Click events | Yes | All historical click analytics | | QR code cache | Yes | Cached QR code images | | Install events | Depends | If cascade delete is configured | **Warning:** This action is **irreversible**. All analytics data for this link will be lost. #### Before You Delete Consider these alternatives: ##### 1. Deactivate Instead Preserve analytics while stopping new clicks: ```bash curl -X PUT https://api.linkforty.com/api/links/abc123 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"isActive": false}' ``` **Benefits:** - Keeps historical data - Can be reactivated later - Analytics remain accessible ##### 2. Set Expiration Auto-expire on specific date: ```bash curl -X PUT https://api.linkforty.com/api/links/abc123 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{"expiresAt": "2024-12-31T23:59:59Z"}' ``` **Benefits:** - Automatic deactivation - Data preserved - Clear lifecycle ##### 3. Export Analytics First Save analytics before deleting: ```bash ### 1. Export analytics curl https://api.linkforty.com/api/analytics/links/abc123 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ > link_analytics.json ### 2. Then delete curl -X DELETE https://api.linkforty.com/api/links/abc123 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` #### TypeScript Example ```typescript async function deleteLink(linkId: string): Promise { const response = await fetch(`https://api.linkforty.com/api/links/${linkId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${API_KEY}`, }, }); if (!response.ok) { throw new Error(`Failed to delete link: ${response.statusText}`); } const result = await response.json(); return result.success; } // Usage try { const success = await deleteLink('link_id'); console.log('Link deleted successfully'); } catch (error) { console.error('Failed to delete:', error); } ``` #### Safe Delete with Confirmation ```typescript async function safeDeleteLink(linkId: string, linkTitle: string) { // Show confirmation const confirmed = window.confirm( `Are you sure you want to delete "${linkTitle}"?\n\n` + `This will permanently remove:\n` + `- Link configuration\n` + `- All click analytics\n` + `- Historical data\n\n` + `This action cannot be undone.` ); if (!confirmed) { return false; } try { await deleteLink(linkId); return true; } catch (error) { alert('Failed to delete link. Please try again.'); return false; } } ``` #### Bulk Delete To delete multiple links, use the [Bulk Operations](https://docs.linkforty.com/api/endpoints/links/bulk-operations/) endpoint: ```bash curl -X POST https://api.linkforty.com/api/links/bulk-delete \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "ids": [ "link_1", "link_2", "link_3" ] }' ``` See [Bulk Operations](https://docs.linkforty.com/api/endpoints/links/bulk-operations/#bulk-delete) for details. #### Error Responses ##### Link Not Found ```json { "statusCode": 404, "error": "Not Found", "message": "Link not found" } ``` **Possible causes:** - Link ID doesn't exist - Link already deleted - Link belongs to different organization ##### Unauthorized ```json { "statusCode": 401, "error": "Unauthorized", "message": "Authentication required" } ``` **Possible causes:** - Missing API key/bearer token - Invalid authentication - Insufficient permissions #### Use Cases ##### 1. Delete Test Links Clean up after testing: ```typescript async function deleteTestLinks() { const links = await getAllLinks(); const testLinks = links.filter( link => link.title?.toLowerCase().includes('test') ); for (const link of testLinks) { await deleteLink(link.id); console.log(`Deleted test link: ${link.shortCode}`); } console.log(`Deleted ${testLinks.length} test links`); } ``` ##### 2. Delete Expired Links Remove links past expiration: ```typescript async function deleteExpiredLinks() { const links = await getAllLinks(); const now = new Date(); const expiredLinks = links.filter(link => { if (!link.expiresAt) return false; return new Date(link.expiresAt) < now; }); for (const link of expiredLinks) { await deleteLink(link.id); console.log(`Deleted expired link: ${link.shortCode}`); } return expiredLinks.length; } ``` ##### 3. Delete Low-Performing Links Remove links with no clicks after 30 days: ```typescript async function deleteInactiveLinks() { const links = await getAllLinks(); const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); const inactiveLinks = links.filter(link => { const created = new Date(link.createdAt); return created < thirtyDaysAgo && link.clickCount === 0; }); console.log(`Found ${inactiveLinks.length} inactive links`); for (const link of inactiveLinks) { await deleteLink(link.id); } } ``` ##### 4. Delete with Backup Export before deleting: ```typescript async function deleteWithBackup(linkId: string) { // 1. Get link details const link = await getLink(linkId); // 2. Get analytics const analytics = await getLinkAnalytics(linkId); // 3. Create backup const backup = { link, analytics, deletedAt: new Date().toISOString(), }; // 4. Save backup await saveToFile(`backup_${link.shortCode}.json`, backup); // 5. Delete link await deleteLink(linkId); console.log(`Link deleted. Backup saved to backup_${link.shortCode}.json`); } ``` #### Best Practices ##### 1. Always Confirm Before Deleting ```typescript function confirmDelete(linkTitle: string): boolean { return window.confirm( `Delete "${linkTitle}"? This action cannot be undone.` ); } if (confirmDelete(link.title)) { await deleteLink(link.id); } ``` ##### 2. Provide Undo Option (Soft Delete) Instead of immediate deletion, mark for deletion: ```typescript // Mark for deletion await updateLink(linkId, { isActive: false, title: `[DELETED] ${link.title}`, }); // Show undo option for 30 seconds showToast('Link deleted', { action: 'Undo', onAction: () => restoreLink(linkId), duration: 30000, }); // Actually delete after 30 days setTimeout(() => deleteLink(linkId), 30 * 24 * 60 * 60 * 1000); ``` ##### 3. Rate Limit Bulk Deletes Avoid overwhelming the server: ```typescript async function rateLimitedBulkDelete(linkIds: string[]) { for (let i = 0; i < linkIds.length; i++) { await deleteLink(linkIds[i]); // Wait 100ms between deletes await new Promise(resolve => setTimeout(resolve, 100)); console.log(`Deleted ${i + 1}/${linkIds.length}`); } } ``` ##### 4. Log Deletions Audit trail for compliance: ```typescript async function loggedDelete(linkId: string, reason: string) { const link = await getLink(linkId); // Log deletion await logAuditEvent({ action: 'link_deleted', linkId: link.id, shortCode: link.shortCode, title: link.title, clickCount: link.clickCount, reason, deletedBy: currentUser.id, deletedAt: new Date(), }); // Delete link await deleteLink(linkId); } ``` ##### 5. Handle Errors Gracefully ```typescript async function safeDelete(linkId: string) { try { await deleteLink(linkId); showSuccess('Link deleted'); } catch (error) { if (error.message.includes('not found')) { showWarning('Link already deleted'); } else if (error.message.includes('unauthorized')) { showError('You don't have permission to delete this link'); } else { showError('Failed to delete link. Please try again.'); } } } ``` #### FAQ ##### Can I recover a deleted link? **No.** Deletion is permanent. The short code becomes available for reuse, and all analytics data is lost. **Alternative:** Use `isActive: false` instead of deleting. ##### Will the short code be available immediately? **Yes.** After deletion, the short code (e.g., "abc123") can be used for a new link. ##### What happens to QR codes? QR codes generated for the link will break - they'll redirect to a 404 page. ##### Can I delete links with active campaigns? **Yes**, but this will break all links in your marketing materials. Consider deactivating instead. ##### Is there a limit on deletions? No explicit limit, but follow [rate limits](https://docs.linkforty.com/api/rate-limits/) (100 requests/minute). #### Related Endpoints - [Get Link](https://docs.linkforty.com/api/endpoints/links/get-link/) - Retrieve link before deleting - [Update Link](https://docs.linkforty.com/api/endpoints/links/update-link/) - Deactivate instead - [Bulk Operations](https://docs.linkforty.com/api/endpoints/links/bulk-operations/) - Delete multiple links - [Link Analytics](https://docs.linkforty.com/api/endpoints/analytics/link-analytics/) - Export analytics first #### Guides - [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) - Link lifecycle - [Link Management Best Practices](https://docs.linkforty.com/guides/links/link-templates/) - When to delete vs deactivate --- ## Bulk Operations Source: https://docs.linkforty.com/api/endpoints/links/bulk-operations/ Create, update, or delete multiple links in a single API request for efficient batch processing. **Info: Core vs Cloud** **Core** requires `userId` and `originalUrl` for each link in bulk operations. **Cloud** requires `templateId` for each link (and derives `userId` from your API key). See [Create Link](https://docs.linkforty.com/api/endpoints/links/create-link/) for the full field reference. #### Overview Bulk operations allow you to process up to **100 links per request**, significantly reducing API calls and improving performance for large-scale link management. **Available Operations:** - Bulk Create - Create multiple links at once - Bulk Update - Update multiple links simultaneously - Bulk Delete - Delete multiple links in one request #### Bulk Create Create up to 100 links in a single request. ##### Endpoint ```http POST /api/links/bulk-create ``` ##### Authentication - **Core (Self-Hosted):** Include `userId` in each link object. See [Authentication](https://docs.linkforty.com/api/authentication/). - **Cloud:** Requires a valid API key in the Authorization header. See [Authentication](https://docs.linkforty.com/api/authentication/). ##### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `links` | array | Yes | Array of link objects (max 100) | Each link object follows the same schema as [Create Link](https://docs.linkforty.com/api/endpoints/links/create-link/). ##### Response ```json { "created": 25, "links": [ { "id": "link_1", "short_code": "abc123", "web_fallback_url": "https://example.com/product/1", "title": "Product 1" } ] } ``` ##### Example Requests **Core:** ```bash curl -X POST https://your-domain.com/api/links/bulk-create \ -H "Content-Type: application/json" \ -d '{ "links": [ { "userId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/product/headphones", "title": "Wireless Headphones" }, { "userId": "550e8400-e29b-41d4-a716-446655440000", "originalUrl": "https://example.com/product/speakers", "title": "Bluetooth Speakers" } ] }' ``` **Cloud:** ```bash curl -X POST https://api.linkforty.com/api/links/bulk-create \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "links": [ { "templateId": "tmpl_123", "webFallbackUrl": "https://example.com/product/headphones", "title": "Wireless Headphones", "utmParameters": { "source": "instagram", "medium": "social", "campaign": "spring-sale" } }, { "templateId": "tmpl_123", "webFallbackUrl": "https://example.com/product/speakers", "title": "Bluetooth Speakers", "utmParameters": { "source": "instagram", "medium": "social", "campaign": "spring-sale" } } ] }' ``` ##### Use Cases ###### 1. Import from CSV ```typescript import { parse } from 'csv-parse/sync'; async function importLinksFromCSV(csvContent: string, templateId: string) { const records = parse(csvContent, { columns: true }); const links = records.map(row => ({ templateId, originalUrl: row.url, title: row.title, description: row.description, utmParameters: { source: row.utm_source, medium: row.utm_medium, campaign: row.utm_campaign, }, })); // Process in batches of 100 const batches = []; for (let i = 0; i < links.length; i += 100) { batches.push(links.slice(i, i + 100)); } for (const batch of batches) { const result = await bulkCreateLinks(batch); console.log(`Created ${result.created} links`); } } ``` ###### 2. Generate Links for Product Catalog ```typescript async function createProductLinks(products: Product[], campaign: string) { const links = products.map(product => ({ templateId: 'product_template', originalUrl: `https://shop.example.com/products/${product.id}`, title: product.name, description: product.description, utmParameters: { source: 'catalog', medium: 'website', campaign, content: product.category, }, })); return bulkCreateLinks(links); } ``` --- #### Bulk Update Update up to 100 links in a single request. ##### Endpoint ```http POST /api/links/bulk-update ``` ##### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `updates` | array | Yes | Array of update objects (max 100) | Each update object contains: | Field | Type | Required | Description | |-------|------|----------|-------------| | `id` | string (UUID) | Yes | Link ID to update | | `data` | object | Yes | Fields to update (same as Update Link) | ##### Response ```json { "updated": 15, "links": [ { "id": "link_1", "shortCode": "abc123", "title": "Updated Title", "updatedAt": "2024-03-15T10:30:00Z" }, // ... 14 more links ] } ``` ##### Example Request ```bash curl -X POST https://api.linkforty.com/api/links/bulk-update \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "updates": [ { "id": "link_1", "data": { "utmParameters": { "campaign": "summer-sale-2024" } } }, { "id": "link_2", "data": { "utmParameters": { "campaign": "summer-sale-2024" } } } ] }' ``` ##### Use Cases ###### 1. Update Campaign for All Links ```typescript async function updateCampaign(linkIds: string[], newCampaign: string) { const updates = linkIds.map(id => ({ id, data: { utmParameters: { campaign: newCampaign, }, }, })); // Process in batches of 100 for (let i = 0; i < updates.length; i += 100) { const batch = updates.slice(i, i + 100); await bulkUpdateLinks(batch); } } ``` ###### 2. Extend Attribution Window ```typescript async function extendAttributionWindow(linkIds: string[], hours: number) { const updates = linkIds.map(id => ({ id, data: { attributionWindowHours: hours }, })); return bulkUpdateLinks(updates); } ``` ###### 3. Deactivate Multiple Links ```typescript async function deactivateLinks(linkIds: string[]) { const updates = linkIds.map(id => ({ id, data: { isActive: false }, })); return bulkUpdateLinks(updates); } ``` ###### 4. Update Links from Spreadsheet ```typescript async function updateFromSpreadsheet(updates: Array<{ shortCode: string; newUrl: string; newTitle: string; }>) { // First, get all links to map short codes to IDs const allLinks = await getAllLinks(); const linkMap = new Map(allLinks.map(l => [l.shortCode, l.id])); // Build update requests const updateRequests = updates .map(update => { const linkId = linkMap.get(update.shortCode); if (!linkId) return null; return { id: linkId, data: { originalUrl: update.newUrl, title: update.newTitle, }, }; }) .filter(Boolean); return bulkUpdateLinks(updateRequests); } ``` --- #### Bulk Delete Delete up to 100 links in a single request. ##### Endpoint ```http POST /api/links/bulk-delete ``` ##### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `ids` | string[] | Yes | Array of link IDs (max 100) | ##### Response ```json { "deleted": 42 } ``` ##### Example Request ```bash curl -X POST https://api.linkforty.com/api/links/bulk-delete \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "ids": [ "link_1", "link_2", "link_3" ] }' ``` ##### Use Cases ###### 1. Delete Test Links ```typescript async function deleteTestLinks() { const links = await getAllLinks(); const testLinkIds = links .filter(link => link.title?.toLowerCase().includes('test')) .map(link => link.id); if (testLinkIds.length === 0) { console.log('No test links found'); return; } // Delete in batches of 100 for (let i = 0; i < testLinkIds.length; i += 100) { const batch = testLinkIds.slice(i, i + 100); const result = await bulkDeleteLinks(batch); console.log(`Deleted ${result.deleted} links`); } } ``` ###### 2. Delete Expired Links ```typescript async function deleteExpiredLinks() { const links = await getAllLinks(); const now = new Date(); const expiredLinkIds = links .filter(link => { if (!link.expiresAt) return false; return new Date(link.expiresAt) < now; }) .map(link => link.id); if (expiredLinkIds.length > 0) { return bulkDeleteLinks(expiredLinkIds); } } ``` ###### 3. Delete Links with No Clicks ```typescript async function deleteUnusedLinks(minAge: number = 30) { const links = await getAllLinks(); const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - minAge); const unusedLinkIds = links .filter(link => { const created = new Date(link.createdAt); return created < cutoffDate && link.clickCount === 0; }) .map(link => link.id); console.log(`Found ${unusedLinkIds.length} unused links`); if (unusedLinkIds.length > 0) { return bulkDeleteLinks(unusedLinkIds); } } ``` --- #### TypeScript Interface ```typescript interface BulkCreateRequest { links: CreateLinkRequest[]; } interface BulkCreateResponse { created: number; links: Link[]; } interface BulkUpdateRequest { updates: Array<{ id: string; data: UpdateLinkRequest; }>; } interface BulkUpdateResponse { updated: number; links: Link[]; } interface BulkDeleteRequest { ids: string[]; } interface BulkDeleteResponse { deleted: number; } // Helper functions async function bulkCreateLinks(links: CreateLinkRequest[]): Promise { const response = await fetch('https://api.linkforty.com/api/links/bulk-create', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ links }), }); return response.json(); } async function bulkUpdateLinks(updates: Array<{ id: string; data: UpdateLinkRequest }>): Promise { const response = await fetch('https://api.linkforty.com/api/links/bulk-update', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ updates }), }); return response.json(); } async function bulkDeleteLinks(ids: string[]): Promise { const response = await fetch('https://api.linkforty.com/api/links/bulk-delete', { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ ids }), }); return response.json(); } ``` #### Error Responses ##### Invalid Array ```json { "statusCode": 400, "error": "Bad Request", "message": "Invalid links array" } ``` **Causes:** - Missing `links` field (bulk create) - Missing `updates` field (bulk update) - Missing `ids` field (bulk delete) - Not an array ##### Too Many Items ```json { "statusCode": 400, "error": "Bad Request", "message": "Maximum 100 links allowed per bulk create" } ``` **Cause:** More than 100 items in request. ##### Validation Error ```json { "statusCode": 400, "error": "Bad Request", "message": "Validation error: Unable to generate unique short code for link at index 42" } ``` **Cause:** One or more links in the batch failed validation. ##### Link Limit Exceeded ```json { "statusCode": 403, "error": "Forbidden", "message": "Link limit exceeded for your organization" } ``` **Cause:** Bulk create would exceed your plan's link limit. **Plan limits:** Free = 1,000 links, Pro = 25,000 links, Business = 100,000 links, Unlimited = No limit. #### Best Practices ##### 1. Process in Batches of 100 ```typescript async function processBatches(items: T[], batchSize: number = 100) { const results = []; for (let i = 0; i < items.length; i += batchSize) { const batch = items.slice(i, i + batchSize); results.push(await processBatch(batch)); console.log(`Processed ${Math.min(i + batchSize, items.length)}/${items.length}`); } return results; } ``` ##### 2. Handle Partial Failures ```typescript async function robustBulkCreate(links: CreateLinkRequest[]) { const results = { succeeded: [] as Link[], failed: [] as { link: CreateLinkRequest; error: string }[], }; // Process in batches for (let i = 0; i < links.length; i += 100) { const batch = links.slice(i, i + 100); try { const response = await bulkCreateLinks(batch); results.succeeded.push(...response.links); } catch (error) { // If batch fails, try individually for (const link of batch) { try { const created = await createLink(link); results.succeeded.push(created); } catch (err) { results.failed.push({ link, error: err.message }); } } } } return results; } ``` ##### 3. Validate Before Sending ```typescript function validateBulkCreate(links: CreateLinkRequest[]): string[] { const errors = []; if (links.length > 100) { errors.push('Maximum 100 links per request'); } links.forEach((link, index) => { if (!link.originalUrl) { errors.push(`Link ${index}: Missing originalUrl`); } if (!link.templateId) { errors.push(`Link ${index}: Missing templateId`); } }); return errors; } // Usage const errors = validateBulkCreate(links); if (errors.length > 0) { console.error('Validation errors:', errors); } else { await bulkCreateLinks(links); } ``` ##### 4. Show Progress ```typescript async function bulkCreateWithProgress(links: CreateLinkRequest[]) { const total = links.length; let created = 0; for (let i = 0; i < links.length; i += 100) { const batch = links.slice(i, i + 100); const result = await bulkCreateLinks(batch); created += result.created; const progress = ((created / total) * 100).toFixed(1); console.log(`Progress: ${created}/${total} (${progress}%)`); updateProgressBar(progress); } console.log('Complete!'); } ``` ##### 5. Retry on Failure ```typescript async function bulkCreateWithRetry( links: CreateLinkRequest[], maxRetries: number = 3 ) { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await bulkCreateLinks(links); } catch (error) { if (attempt === maxRetries) throw error; console.log(`Attempt ${attempt} failed, retrying...`); await new Promise(resolve => setTimeout(resolve, 1000 * attempt)); } } } ``` #### Limitations | Limit | Value | |-------|-------| | Max items per request | 100 | | Request size | ~1 MB | | Rate limit | [Standard rate limits](https://docs.linkforty.com/api/rate-limits/) apply | #### Performance Tips 1. **Use bulk operations instead of loops** - 100 links in 1 request = 1 API call (good) - 100 links in loop = 100 API calls (bad) 2. **Batch your operations** - Process 100 at a time - Show progress to user - Handle failures gracefully 3. **Validate client-side first** - Check URLs are valid - Ensure required fields present - Reduce failed requests 4. **Use compression** - Enable gzip for large payloads - Reduces transfer time #### Related Endpoints - [Create Link](https://docs.linkforty.com/api/endpoints/links/create-link/) - Single link creation - [Update Link](https://docs.linkforty.com/api/endpoints/links/update-link/) - Single link update - [Delete Link](https://docs.linkforty.com/api/endpoints/links/delete-link/) - Single link deletion - [Get Link](https://docs.linkforty.com/api/endpoints/links/get-link/) - Retrieve link details #### Guides - [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) - Link management basics - [Link Templates](https://docs.linkforty.com/guides/links/link-templates/) - Using templates efficiently - [UTM Parameters](https://docs.linkforty.com/guides/links/utm-parameters/) - Bulk campaign tracking --- # Endpoints: Analytics ## Link Analytics Source: https://docs.linkforty.com/api/endpoints/analytics/link-analytics/ Retrieve detailed click analytics and performance metrics for your links. #### Overview LinkForty provides comprehensive analytics for every link, including: - Click counts (total and unique) - Geographic distribution - Device and platform breakdown - Time-based patterns - UTM campaign performance - Referrer analysis #### Get Link Analytics ```http GET /api/analytics/links/:linkId ``` Retrieve detailed analytics for a specific link. ##### Authentication - **Core (Self-Hosted):** Pass `userId` as a query parameter. See [Authentication](https://docs.linkforty.com/api/authentication/). - **Cloud:** Requires a valid API key in the Authorization header. See [Authentication](https://docs.linkforty.com/api/authentication/). ##### Path Parameters | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `linkId` | string (UUID) | Yes | Link ID | ##### Query Parameters | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `userId` | string (UUID) | Core only | — | Required for Core to verify ownership | | `days` | number | No | 30 | Number of days of data to include (1-365) | ##### Response ```json { "totalClicks": 1248, "uniqueClicks": 892, "clicksByDate": [ { "date": "2024-03-15", "clicks": 45 }, { "date": "2024-03-14", "clicks": 38 } ], "clicksByCountry": [ { "countryCode": "US", "country": "United States", "clicks": 523 }, { "countryCode": "CA", "country": "Canada", "clicks": 187 } ], "clicksByCity": [ { "city": "New York", "countryCode": "US", "region": "NY", "clicks": 142 }, { "city": "Los Angeles", "countryCode": "US", "region": "CA", "clicks": 98 } ], "clicksByRegion": [ { "region": "California", "countryCode": "US", "clicks": 256 } ], "clicksByTimezone": [ { "timezone": "America/New_York", "clicks": 312 } ], "clicksByDevice": [ { "device": "mobile", "clicks": 742 }, { "device": "desktop", "clicks": 398 }, { "device": "tablet", "clicks": 108 } ], "clicksByPlatform": [ { "platform": "iOS", "clicks": 623 }, { "platform": "Android", "clicks": 425 }, { "platform": "Windows", "clicks": 200 } ] } ``` ##### Example Requests **Core - Last 30 days (default):** ```bash curl "https://your-domain.com/api/analytics/links/a1b2c3d4-e5f6-7890-abcd-ef1234567890?userId=550e8400-e29b-41d4-a716-446655440000" ``` **Cloud - Last 30 days (default):** ```bash curl https://api.linkforty.com/api/analytics/links/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Last 7 days:** ```bash curl "https://api.linkforty.com/api/analytics/links/a1b2c3d4-e5f6-7890-abcd-ef1234567890?days=7" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` **Last 90 days:** ```bash curl "https://api.linkforty.com/api/analytics/links/a1b2c3d4-e5f6-7890-abcd-ef1234567890?days=90" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` #### Get Click Events ```http GET /api/analytics/clicks/:linkId ``` Retrieve raw click event data for detailed analysis. ##### Query Parameters | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `limit` | number | No | 100 | Maximum events to return (1-1000) | ##### Response ```json [ { "id": "click_1", "linkId": "link_abc123", "clickedAt": "2024-03-15T14:23:12Z", "ipAddress": "192.168.1.1", "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", "deviceType": "mobile", "platform": "iOS", "browser": "Safari", "browserVersion": "17.0", "os": "iOS", "osVersion": "17.0", "countryCode": "US", "countryName": "United States", "region": "California", "city": "San Francisco", "timezone": "America/Los_Angeles", "latitude": 37.7749, "longitude": -122.4194, "utmSource": "instagram", "utmMedium": "social", "utmCampaign": "spring-sale", "utmTerm": null, "utmContent": "story-1", "referrer": "https://www.instagram.com/" } ] ``` ##### Example Request ```bash curl "https://api.linkforty.com/api/analytics/clicks/a1b2c3d4-e5f6-7890-abcd-ef1234567890?limit=50" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` #### TypeScript Examples ##### 1. Display Analytics Dashboard ```typescript interface LinkAnalytics { totalClicks: number; uniqueClicks: number; clicksByDate: Array<{ date: string; clicks: number }>; clicksByCountry: Array<{ countryCode: string; country: string; clicks: number }>; clicksByDevice: Array<{ device: string; clicks: number }>; clicksByPlatform: Array<{ platform: string; clicks: number }>; } async function getLinkAnalytics(linkId: string, days: number = 30): Promise { const response = await fetch( `https://api.linkforty.com/api/analytics/links/${linkId}?days=${days}`, { headers: { 'Authorization': `Bearer ${API_KEY}` }, } ); return response.json(); } // Usage const analytics = await getLinkAnalytics('link_id', 7); console.log(`Total Clicks: ${analytics.totalClicks}`); console.log(`Unique Visitors: ${analytics.uniqueClicks}`); console.log(`Top Country: ${analytics.clicksByCountry[0].country}`); console.log(`Primary Device: ${analytics.clicksByDevice[0].device}`); ``` ##### 2. Calculate Conversion Rate ```typescript async function getConversionRate(linkId: string) { const analytics = await getLinkAnalytics(linkId); const conversions = 42; // From your conversion tracking const conversionRate = (conversions / analytics.totalClicks) * 100; return { clicks: analytics.totalClicks, conversions, rate: conversionRate.toFixed(2) + '%', }; } ``` ##### 3. Compare Link Performance ```typescript async function compareLinks(linkIds: string[], days: number = 30) { const results = await Promise.all( linkIds.map(async id => { const analytics = await getLinkAnalytics(id, days); const link = await getLink(id); return { linkId: id, shortCode: link.shortCode, title: link.title, clicks: analytics.totalClicks, uniqueClicks: analytics.uniqueClicks, ctr: ((analytics.uniqueClicks / analytics.totalClicks) * 100).toFixed(1), }; }) ); // Sort by clicks return results.sort((a, b) => b.clicks - a.clicks); } // Usage const comparison = await compareLinks(['link1', 'link2', 'link3']); console.table(comparison); ``` ##### 4. Generate Analytics Report ```typescript async function generateReport(linkId: string, days: number = 30) { const analytics = await getLinkAnalytics(linkId, days); const link = await getLink(linkId); return { summary: { linkTitle: link.title, shortCode: link.shortCode, period: `Last ${days} days`, totalClicks: analytics.totalClicks, uniqueClicks: analytics.uniqueClicks, avgClicksPerDay: (analytics.totalClicks / days).toFixed(1), }, geographic: { topCountries: analytics.clicksByCountry.slice(0, 5), topCities: analytics.clicksByCity?.slice(0, 5) || [], }, devices: { byType: analytics.clicksByDevice, byPlatform: analytics.clicksByPlatform, mobilePercentage: ( (analytics.clicksByDevice.find(d => d.device === 'mobile')?.clicks || 0) / analytics.totalClicks * 100 ).toFixed(1), }, timeline: analytics.clicksByDate, }; } ``` ##### 5. Export to CSV ```typescript async function exportAnalyticsToCSV(linkId: string, days: number = 30) { const clicks = await fetch( `https://api.linkforty.com/api/analytics/clicks/${linkId}?limit=1000`, { headers: { 'Authorization': `Bearer ${API_KEY}` } } ).then(r => r.json()); const csv = [ // Header 'Date,Time,Country,City,Device,Platform,Browser,UTM Source,UTM Campaign', // Rows ...clicks.map(click => [ new Date(click.clickedAt).toLocaleDateString(), new Date(click.clickedAt).toLocaleTimeString(), click.countryName || 'Unknown', click.city || 'Unknown', click.deviceType || 'Unknown', click.platform || 'Unknown', click.browser || 'Unknown', click.utmSource || 'Direct', click.utmCampaign || 'None', ].join(',')) ].join('\n'); return csv; } // Save to file const csv = await exportAnalyticsToCSV('link_id'); await fs.writeFile('analytics.csv', csv); ``` #### Common Analytics Patterns ##### 1. Track Campaign Performance ```typescript async function getCampaignPerformance(linkId: string) { const analytics = await getLinkAnalytics(linkId); const clicks = await getClickEvents(linkId, 1000); // Group by UTM campaign const byCampaign = clicks.reduce((acc, click) => { const campaign = click.utmCampaign || 'None'; acc[campaign] = (acc[campaign] || 0) + 1; return acc; }, {} as Record); return Object.entries(byCampaign) .map(([campaign, clicks]) => ({ campaign, clicks })) .sort((a, b) => b.clicks - a.clicks); } ``` ##### 2. Find Peak Traffic Times ```typescript async function getPeakHours(linkId: string) { const clicks = await getClickEvents(linkId, 1000); const byHour = clicks.reduce((acc, click) => { const hour = new Date(click.clickedAt).getHours(); acc[hour] = (acc[hour] || 0) + 1; return acc; }, {} as Record); const sorted = Object.entries(byHour) .map(([hour, clicks]) => ({ hour: parseInt(hour), clicks })) .sort((a, b) => b.clicks - a.clicks); return { peakHour: sorted[0].hour, peakClicks: sorted[0].clicks, distribution: sorted, }; } ``` ##### 3. Calculate Growth Rate ```typescript async function getGrowthRate(linkId: string) { const last7Days = await getLinkAnalytics(linkId, 7); const prev7Days = await getLinkAnalytics(linkId, 14); const currentWeek = last7Days.totalClicks; const previousWeek = prev7Days.totalClicks - currentWeek; const growth = ((currentWeek - previousWeek) / previousWeek) * 100; return { currentWeek, previousWeek, growth: growth.toFixed(1) + '%', isGrowing: growth > 0, }; } ``` ##### 4. Detect Geographic Patterns ```typescript async function getGeographicInsights(linkId: string) { const analytics = await getLinkAnalytics(linkId); const totalClicks = analytics.totalClicks; return { topCountry: analytics.clicksByCountry[0], diversity: analytics.clicksByCountry.length, concentration: ( (analytics.clicksByCountry[0]?.clicks || 0) / totalClicks * 100 ).toFixed(1) + '%', internationalPercentage: ( analytics.clicksByCountry .filter(c => c.countryCode !== 'US') .reduce((sum, c) => sum + c.clicks, 0) / totalClicks * 100 ).toFixed(1) + '%', }; } ``` #### Error Responses ##### Link Not Found ```json { "statusCode": 404, "error": "Not Found", "message": "Link not found" } ``` ##### Invalid Days Parameter ```json { "statusCode": 400, "error": "Bad Request", "message": "Days must be between 1 and 365" } ``` #### Best Practices ##### 1. Cache Analytics Data ```typescript const analyticsCache = new Map(); async function getCachedAnalytics(linkId: string, ttl: number = 300000) { const cached = analyticsCache.get(linkId); if (cached && Date.now() - cached.timestamp < ttl) { return cached.data; } const data = await getLinkAnalytics(linkId); analyticsCache.set(linkId, { data, timestamp: Date.now() }); return data; } ``` ##### 2. Handle Large Date Ranges ```typescript async function getAnalyticsInChunks(linkId: string, totalDays: number) { const chunkSize = 30; const chunks = Math.ceil(totalDays / chunkSize); const results = []; for (let i = 0; i < chunks; i++) { const days = Math.min(chunkSize, totalDays - i * chunkSize); const analytics = await getLinkAnalytics(linkId, days); results.push(analytics); // Wait between requests await new Promise(resolve => setTimeout(resolve, 100)); } return mergeAnalytics(results); } ``` ##### 3. Real-Time Updates ```typescript function useRealtimeAnalytics(linkId: string, refreshInterval: number = 60000) { const [analytics, setAnalytics] = useState(null); useEffect(() => { const fetchAnalytics = async () => { const data = await getLinkAnalytics(linkId, 1); // Last 24 hours setAnalytics(data); }; fetchAnalytics(); const interval = setInterval(fetchAnalytics, refreshInterval); return () => clearInterval(interval); }, [linkId, refreshInterval]); return analytics; } ``` #### Related Endpoints - [Get Link](https://docs.linkforty.com/api/endpoints/links/get-link/) - Link details and basic click count - [Event Analytics](https://docs.linkforty.com/api/endpoints/analytics/event-analytics/) - In-app event and conversion data - [Analytics Dashboard](https://docs.linkforty.com/guides/analytics/dashboard/) - Organization-wide analytics (overview API) - [Data Export](https://docs.linkforty.com/guides/analytics/data-export/) - Export raw analytics data #### Guides - [Analytics Dashboard](https://docs.linkforty.com/guides/analytics/dashboard/) - Understanding your analytics - [Campaign Tracking](https://docs.linkforty.com/guides/links/utm-parameters/) - Setting up UTM tracking - [Attribution Windows](https://docs.linkforty.com/guides/attribution/attribution-windows/) - Configuring attribution --- ## Event Analytics Source: https://docs.linkforty.com/api/endpoints/analytics/event-analytics/ Query in-app event data tracked by your mobile SDKs. #### Overview These endpoints expose event data from your mobile app's `trackEvent()` and `trackRevenue()` calls. All endpoints are scoped to your organization and require the `analytics:events` permission (Pro and Unlimited plans). #### Get Event Overview ```http GET /api/analytics/events/overview ``` Retrieve summary statistics for the Events dashboard header cards. ##### Authentication Requires a valid JWT or API key. See [Authentication](https://docs.linkforty.com/api/authentication/). ##### Query Parameters | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `days` | number | No | 30 | Number of days to include (1-365) | ##### Response ```json { "totalEvents": 4821, "uniqueEventNames": 12, "eventsToday": 87, "totalRevenue": 2493.50, "topEvents": [ { "eventName": "screen_view", "count": 1892, "lastOccurred": "2026-03-03T18:42:00.000Z" }, { "eventName": "purchase", "count": 634, "lastOccurred": "2026-03-03T18:38:12.000Z" }, { "eventName": "signup", "count": 312, "lastOccurred": "2026-03-03T17:55:00.000Z" } ] } ``` ##### Example ```bash curl "https://api.linkforty.com/api/analytics/events/overview?days=30" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` --- #### Get Event Timeline ```http GET /api/analytics/events/timeline ``` Retrieve daily event counts for charting. ##### Query Parameters | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `days` | number | No | 30 | Number of days to include | | `eventName` | string | No | — | Filter to a specific event name | ##### Response Returns an array with one entry per day. Days with no events have `count: 0`. ```json { "timeline": [ { "date": "2026-02-01", "count": 45 }, { "date": "2026-02-02", "count": 0 }, { "date": "2026-02-03", "count": 72 } ] } ``` ##### Example ```bash ### All events, last 7 days curl "https://api.linkforty.com/api/analytics/events/timeline?days=7" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ### Only purchase events curl "https://api.linkforty.com/api/analytics/events/timeline?days=30&eventName=purchase" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` --- #### List Events ```http GET /api/analytics/events/list ``` Retrieve a paginated feed of individual events. ##### Query Parameters | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `days` | number | No | 30 | Number of days to include | | `eventName` | string | No | — | Filter to a specific event name | | `page` | number | No | 1 | Page number | | `limit` | number | No | 50 | Events per page (max 100) | | `search` | string | No | — | Search event names (case-insensitive) | ##### Response ```json { "events": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "eventName": "purchase", "eventData": { "revenue": 29.99, "currency": "USD", "productId": "sku-123" }, "eventTimestamp": "2026-03-03T18:38:12.000Z", "installId": "f1e2d3c4-b5a6-0987-fedc-ba0987654321", "linkId": "11223344-5566-7788-99aa-bbccddeeff00", "linkShortCode": "abc123", "linkTitle": "Spring Sale Campaign" } ], "total": 634, "page": 1, "limit": 50 } ``` ##### Example ```bash curl "https://api.linkforty.com/api/analytics/events/list?page=1&limit=10&eventName=purchase" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` --- #### Get Event Names ```http GET /api/analytics/events/names ``` Retrieve all distinct event names with their total counts. Useful for populating filter dropdowns. ##### Response ```json { "eventNames": [ { "name": "screen_view", "count": 1892 }, { "name": "purchase", "count": 634 }, { "name": "signup", "count": 312 }, { "name": "revenue", "count": 289 }, { "name": "add_to_cart", "count": 178 } ] } ``` ##### Example ```bash curl "https://api.linkforty.com/api/analytics/events/names" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ``` --- #### Export Events ```http GET /api/analytics/events/export ``` Export raw event data in JSON or CSV format. Requires the `analytics:export` permission. ##### Query Parameters | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `days` | number | No | 30 | Number of days to include | | `eventName` | string | No | — | Filter to a specific event name | | `format` | string | No | `json` | `json` or `csv` | | `limit` | number | No | 1000 | Max events to return | | `offset` | number | No | 0 | Offset for pagination | ##### JSON Response ```json { "data": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "eventName": "purchase", "eventData": { "revenue": 29.99, "currency": "USD" }, "eventTimestamp": "2026-03-03T18:38:12.000Z", "installId": "f1e2d3c4-b5a6-0987-fedc-ba0987654321", "linkId": "11223344-5566-7788-99aa-bbccddeeff00", "linkShortCode": "abc123", "linkTitle": "Spring Sale Campaign" } ], "pagination": { "total": 634, "limit": 1000, "offset": 0, "hasMore": false } } ``` ##### CSV Response When `format=csv`, the response is plain text CSV: ```csv id,eventName,eventData,eventTimestamp,installId,linkId,linkShortCode,linkTitle a1b2c3d4-...,purchase,"{""revenue"":29.99}",2026-03-03T18:38:12.000Z,f1e2d3c4-...,11223344-...,abc123,Spring Sale Campaign ``` ##### Examples ```bash ### JSON export curl "https://api.linkforty.com/api/analytics/events/export?days=30&format=json" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" ### CSV export, only purchase events curl "https://api.linkforty.com/api/analytics/events/export?days=90&eventName=purchase&format=csv" \ -H "Authorization: Bearer $LINKFORTY_API_KEY" \ -o events.csv ``` --- #### Revenue Convention All LinkForty SDKs use a standardized format for revenue events: | Field | Value | |-------|-------| | Event name | `revenue` | | `eventData.revenue` | Numeric amount (e.g., `29.99`) | | `eventData.currency` | ISO 4217 currency code (e.g., `USD`) | The `totalRevenue` field in the overview endpoint sums `eventData.revenue` from all events with `eventName = 'revenue'`. Use `trackRevenue()` in the SDK rather than manually calling `trackEvent('revenue', ...)` to ensure this format: ```typescript // Correct - uses standardized format LinkForty.trackRevenue(29.99, 'USD', { orderId: 'order-123' }); // Also works, but prefer trackRevenue() LinkForty.trackEvent('revenue', { revenue: 29.99, currency: 'USD', orderId: 'order-123' }); ``` #### Error Responses | Status | Description | |--------|-------------| | 401 | Missing or invalid authentication | | 403 | Plan does not include event analytics (Free tier) | | 429 | API rate limit exceeded | #### Permissions All event analytics endpoints require the `analytics:events` permission, available to **owner**, **admin**, and **member** roles on Pro and Unlimited plans. The export endpoint additionally requires `analytics:export`. --- # Mobile SDKs ## React Native SDK Source: https://docs.linkforty.com/sdks/react-native/ Integrate deep linking and mobile attribution into your React Native app with the LinkForty SDK. **Tip: Expo app?** If you're building with Expo, use the [Expo SDK](https://docs.linkforty.com/sdks/expo/) instead. It provides the same features with a pure-JS implementation and no native code required. **Tip: Faster setup with AI** Already using Claude Code, Cursor, or Claude Desktop? The [LinkForty MCP server](https://docs.linkforty.com/guides/advanced/mcp-server/) can scaffold your SDK integration automatically. Just ask: *"Help me add the LinkForty SDK to my React Native app."* #### Features - **Deferred deep linking** -- route new users to specific content after install - **Direct deep linking** -- handle links when the app is already installed - **Install attribution** -- match installs to the link click that drove them - **Event tracking** -- log in-app events tied to attribution data - **Programmatic link creation** -- create short links from your app - **Server-side URL resolution** -- resolve Universal Links / App Links for enriched data - **TypeScript support** -- full type definitions included - **Cross-platform** -- iOS and Android with a single codebase #### Requirements - React Native >= 0.64.0 - React >= 17.0.0 - Node.js >= 20.0.0 #### Installation ```bash npm install @linkforty/mobile-sdk-react-native ``` The SDK bundles `@react-native-async-storage/async-storage` and `react-native-device-info` as direct dependencies -- no additional peer dependency installs are needed. ##### iOS After installing, run: ```bash cd ios && pod install && cd .. ``` ##### Android No additional setup required. Auto-linking handles native module registration. #### Platform Configuration ##### iOS -- Universal Links 1. In Xcode, add the **Associated Domains** capability to your target 2. Add your domain: `applinks:go.yourdomain.com` 3. Ensure your LinkForty server hosts the Apple App Site Association file at `/.well-known/apple-app-site-association` (LinkForty serves this automatically) ##### Android -- App Links Add an intent filter to your `AndroidManifest.xml`: ```xml ``` Ensure your LinkForty server hosts the Digital Asset Links file at `/.well-known/assetlinks.json` (LinkForty serves this automatically). --- #### Quick Start ##### Initialize the SDK Call `init()` once at app startup, before registering any callbacks: ```typescript import React, { useEffect } from 'react'; import LinkForty, { DeepLinkData } from '@linkforty/mobile-sdk-react-native'; function App() { useEffect(() => { LinkForty.init({ baseUrl: 'https://go.yourdomain.com', apiKey: 'your-api-key', // optional -- required for link creation appToken: 'at_your_app_token', // recommended for Cloud -- enables organic-install attribution debug: __DEV__, }); // Handle deferred deep links (new installs) LinkForty.onDeferredDeepLink((data: DeepLinkData | null) => { if (data) { console.log('Attributed install:', data); navigateToContent(data); } }); // Handle direct deep links (app already installed) LinkForty.onDeepLink((url: string, data: DeepLinkData | null) => { console.log('Deep link opened:', url); if (data) { navigateToContent(data); } }); }, []); const navigateToContent = (data: DeepLinkData) => { const productId = data.customParameters?.productId; if (productId) { // Navigate to product screen } }; return (/* your app */); } ``` ##### Track Events ```typescript import LinkForty from '@linkforty/mobile-sdk-react-native'; // Track a custom event LinkForty.trackEvent('purchase', { value: 29.99, currency: 'USD', productId: '123', }); // Track a signup LinkForty.trackEvent('signup', { method: 'email' }); // Track revenue (standardized format for dashboard aggregation) LinkForty.trackRevenue(29.99, 'USD', { productId: '123', orderId: 'order-456' }); ``` ##### Create Links ```typescript import LinkForty from '@linkforty/mobile-sdk-react-native'; const result = await LinkForty.createLink({ deepLinkParameters: { route: 'PRODUCT', id: '123' }, title: 'Check out this product', utmParameters: { source: 'app', medium: 'share' }, }); console.log(result.url); // https://go.yourdomain.com/abc123 console.log(result.shortCode); // abc123 console.log(result.linkId); // uuid ``` **Warning:** Link creation requires an API key. Pass `apiKey` in your `init()` configuration. The simplified endpoint (`/api/sdk/v1/links`) is only available on LinkForty Cloud, not self-hosted Core. --- #### API Reference ##### `init(config)` Initialize the SDK. Must be called before any other method. Idempotent -- calling it twice logs a warning and returns. ```typescript await LinkForty.init(config: LinkFortyConfig): Promise ``` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `baseUrl` | string | Yes | Your LinkForty server URL | | `apiKey` | string | No | API key for link creation and Cloud features | | `appToken` | string | No | Public workspace token. **Recommended for Cloud** — required for organic installs (App Store discovery, social mentions, etc.) to be attributed to your workspace. Find it in the dashboard under Workspace Settings → App Token. Safe to ship in your app bundle. | | `debug` | boolean | No | Enable verbose logging (default: `false`) | | `attributionWindow` | number | No | Attribution window in days (default: `7`) | | `autoTrackNavigation` | boolean \| object | No | Auto-emit `screen_view` events from React Navigation. Requires `navigationRef`. See [Automatic Screen Tracking](#automatic-screen-tracking). Default: off. | | `navigationRef` | ref | No | Your React Navigation container ref. Required when `autoTrackNavigation` is enabled. | ##### `onDeferredDeepLink(callback)` Register a callback for deferred deep links. If attribution data is already available (e.g., callback registered after init completes), the callback fires immediately. ```typescript LinkForty.onDeferredDeepLink(callback: (data: DeepLinkData | null) => void): void ``` The callback receives `null` for organic installs (no attribution match). ##### `onDeepLink(callback)` Register a callback for direct deep links. When a LinkForty URL opens the app, the SDK resolves it server-side for enriched data, then invokes the callback. ```typescript LinkForty.onDeepLink(callback: (url: string, data: DeepLinkData | null) => void): void ``` ##### `trackEvent(name, properties?)` Track an in-app event. Requires a successful install report (install ID must be available). ```typescript await LinkForty.trackEvent(name: string, properties?: Record): Promise ``` ##### `trackRevenue(amount, currency, properties?)` Track a revenue event using the standardized format. The amount must be non-negative. All four LinkForty SDKs use the same convention so the [Events dashboard](https://docs.linkforty.com/guides/analytics/events/) can aggregate revenue. ```typescript await LinkForty.trackRevenue(amount: number, currency: string, properties?: Record): Promise ``` | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `amount` | number | Yes | Non-negative revenue amount | | `currency` | string | Yes | ISO 4217 currency code (e.g., `USD`) | | `properties` | Record\ | No | Additional event properties | Internally calls `trackEvent('revenue', { revenue: amount, currency, ...properties })`. ##### `createLink(options)` Create a short link programmatically. Requires an API key. ```typescript await LinkForty.createLink(options: CreateLinkOptions): Promise ``` | Option | Type | Required | Description | |--------|------|----------|-------------| | `templateId` | string | No | Template UUID (uses organization's default template if omitted) | | `templateSlug` | string | No | Template slug for URL construction | | `deepLinkParameters` | Record\ | No | In-app routing parameters | | `title` | string | No | Link title | | `description` | string | No | Link description | | `customCode` | string | No | Custom short code | | `utmParameters` | UTMParameters | No | Campaign tracking parameters | | `externalUserId` | string | No | User ID for deduplication and share attribution | **Returns:** `{ url: string, shortCode: string, linkId: string, deduplicated?: boolean }` ##### `setExternalUserId(id)` Set the external user ID for share attribution. This ID is automatically attached to all `createLink()` calls unless overridden per-call. Pass `null` to clear. ```typescript LinkForty.setExternalUserId(id: string | null): void ``` ##### `getExternalUserId()` Returns the current external user ID, or `null` if not set. ```typescript LinkForty.getExternalUserId(): string | null ``` ##### `getInstallId()` Get the server-assigned install UUID. Returns `null` if no install has been reported. ```typescript await LinkForty.getInstallId(): Promise ``` ##### `getInstallData()` Retrieve cached attribution data from local storage. Returns `null` for organic installs. ```typescript await LinkForty.getInstallData(): Promise ``` ##### `clearData()` Wipe all locally stored SDK data (install ID, attribution data, first-launch flag). The next app launch will be treated as a fresh install. ```typescript await LinkForty.clearData(): Promise ``` --- #### Types ```typescript import type { LinkFortyConfig, DeepLinkData, InstallAttributionResponse, CreateLinkOptions, CreateLinkResult, DeferredDeepLinkCallback, DeepLinkCallback, } from '@linkforty/mobile-sdk-react-native'; ``` ##### DeepLinkData | Field | Type | Description | |-------|------|-------------| | `shortCode` | string | The link's short code | | `iosUrl` | string? | iOS destination URL | | `androidUrl` | string? | Android destination URL | | `webUrl` | string? | Web fallback URL | | `utmParameters` | UTMParameters? | UTM tracking parameters | | `customParameters` | Record\? | Custom query parameters | | `deepLinkPath` | string? | In-app routing path (e.g., `/product/123`) | | `appScheme` | string? | App URI scheme (e.g., `myapp`) | | `clickedAt` | string? | When the link was clicked (ISO 8601) | | `linkId` | string? | Link UUID | ##### UTMParameters | Field | Type | |-------|------| | `source` | string? | | `medium` | string? | | `campaign` | string? | | `term` | string? | | `content` | string? | --- #### Automatic Screen Tracking Enable `autoTrackNavigation` to emit a `screen_view` event on every React Navigation transition — no manual `trackEvent` calls per screen. Screen views flow through the normal event pipeline and carry the active deep-link attribution context, so they power [App Analytics](https://docs.linkforty.com/guides/analytics/app-analytics/) screen flow and the [SDK health](https://docs.linkforty.com/guides/attribution/sdk-health/) "events flowing" signal. It requires a [navigation container ref](https://reactnavigation.org/docs/navigation-container/#ref). Pass it as `navigationRef`: ```typescript import { createNavigationContainerRef, NavigationContainer } from '@react-navigation/native'; import LinkForty from '@linkforty/mobile-sdk-react-native'; export const navigationRef = createNavigationContainerRef(); LinkForty.init({ baseUrl: 'https://go.yourdomain.com', appToken: 'at_your_app_token', autoTrackNavigation: true, // screen names only — privacy-safe default navigationRef, }); // Attach the same ref to your container {/* ...screens... */} ``` ##### Capturing route params By default, `autoTrackNavigation: true` captures **screen names only** — never route params, since params can hold personal data. To capture specific, non-PII params, pass an options object with an explicit allow-list: ```typescript LinkForty.init({ baseUrl: 'https://go.yourdomain.com', navigationRef, autoTrackNavigation: { captureParams: ['productId', 'category'], // only these keys are captured debounceMs: 350, // collapse rapid transitions (default 350ms) }, }); ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `captureParams` | string[] | `[]` (none) | Allow-list of route param keys whose primitive values may be captured. Never list keys that can hold personal data. | | `debounceMs` | number | `350` | Debounce window for rapid transitions, in milliseconds. | ##### What gets sent Each transition emits an event named `screen_view` with: | Property | Description | |----------|-------------| | `screen` | The active route name | | `previousScreen` | The route navigated from (when available) | | `params` | Allow-listed route params (only if `captureParams` is set) | Rapid transitions are debounced to the final screen and consecutive duplicates are dropped. Apps that don't use React Navigation are unaffected — if `autoTrackNavigation` is enabled without a `navigationRef`, screen tracking is simply disabled (a warning is logged in `debug` mode). **Info: SDK version** Automatic screen tracking is available in the SDK versions that ship the navigation tracker. Check the [SDK health page](https://docs.linkforty.com/guides/attribution/sdk-health/) to confirm your installed version supports it. --- #### Self-Hosted Configuration If you're running [LinkForty Core](https://docs.linkforty.com/getting-started/self-hosting/) instead of Cloud, omit the `apiKey`: ```typescript await LinkForty.init({ baseUrl: 'https://links.yourcompany.com', debug: false, }); ``` Link creation via `createLink()` without a `templateId` is not available on self-hosted Core -- you must provide a `templateId` and use the `/api/links` endpoint. --- #### Troubleshooting ##### Deferred deep link callback not firing 1. **Attribution window expired** -- the user installed too long after clicking the link 2. **Fingerprint mismatch** -- different network between click and install, VPN, or iOS privacy relay 3. **SDK not initialized** -- ensure `init()` is called before `onDeferredDeepLink()` 4. **Enable debug mode** -- set `debug: true` to see detailed logs ##### Events not appearing in analytics 1. **No install ID** -- events require a successful install report. Check debug logs for install errors 2. **Network issues** -- the React Native SDK does not queue events offline (events are fire-and-forget) ##### iOS build errors ```bash cd ios && pod deintegrate && pod install && cd .. ``` ##### Android build errors Ensure `minSdkVersion` is at least 21 in `android/build.gradle`. --- #### Parity Notes The React Native SDK has some gaps compared to the [iOS](https://docs.linkforty.com/sdks/ios/), [Android](https://docs.linkforty.com/sdks/android/), and [Expo](https://docs.linkforty.com/sdks/expo/) SDKs: - **No offline event queue** -- events are fire-and-forget; failed events are not retried - **No `reset()` method** -- only `clearData()` is available - **No typed errors** -- throws generic `Error` instead of typed error codes - **No configuration validation** -- HTTPS and attribution window bounds are not enforced See the [SDK Specification](https://docs.linkforty.com/sdks/sdk-specification/) for the full feature parity matrix. --- #### Next Steps - [Create links](https://docs.linkforty.com/guides/links/creating-links/) in the dashboard - [View analytics](https://docs.linkforty.com/guides/analytics/dashboard/) for your app - [Configure attribution windows](https://docs.linkforty.com/guides/attribution/attribution-windows/) - [SDK Specification](https://docs.linkforty.com/sdks/sdk-specification/) -- full feature reference #### Resources - [GitHub](https://github.com/linkforty/mobile-sdk-react-native) - [npm](https://www.npmjs.com/package/@linkforty/mobile-sdk-react-native) - [Report an issue](https://github.com/linkforty/mobile-sdk-react-native/issues) --- ## Expo SDK Source: https://docs.linkforty.com/sdks/expo/ Deep linking and mobile attribution for Expo apps. Pure JavaScript implementation -- no native code or ejecting required. **Tip: React Native CLI?** If you're using React Native CLI (not Expo), use the [React Native SDK](https://docs.linkforty.com/sdks/react-native/) instead. **Tip: Faster setup with AI** Already using Claude Code, Cursor, or Claude Desktop? The [LinkForty MCP server](https://docs.linkforty.com/guides/advanced/mcp-server/) can scaffold your SDK integration automatically. Just ask: *"Help me add the LinkForty SDK to my Expo app."* #### Features - **Deferred deep linking** -- route new users to specific content after install - **Direct deep linking** -- handle links when the app is already installed - **Install attribution** -- probabilistic fingerprint matching - **Event tracking** -- log in-app events tied to attribution data - **Revenue tracking** -- structured purchase tracking with dedicated method - **Offline event queue** -- events persist across app restarts and retry automatically - **Programmatic link creation** -- create short links from your app - **Typed errors** -- `LinkFortyError` with specific error codes - **Pure Expo modules** -- uses expo-device, expo-application, expo-localization, expo-linking - **No native code required** -- works with Expo Go and EAS builds #### Requirements - Expo SDK 50+ - React Native >= 0.73.0 #### Installation ```bash npx expo install @linkforty/mobile-sdk-expo expo-device expo-application expo-localization expo-linking @react-native-async-storage/async-storage ``` --- #### Quick Start ##### Initialize the SDK ```typescript import { LinkForty } from '@linkforty/mobile-sdk-expo'; import { useEffect } from 'react'; function App() { useEffect(() => { async function setup() { await LinkForty.initialize({ baseUrl: 'https://go.yourdomain.com', apiKey: 'your-api-key', // optional -- required for link creation appToken: 'at_your_app_token', // recommended for Cloud -- enables organic-install attribution debug: __DEV__, attributionWindowHours: 168, // 7 days (default) }); // Handle deferred deep links (new installs) LinkForty.onDeferredDeepLink((data) => { if (data) { console.log('Attributed install:', data.shortCode); navigateToContent(data); } }); // Handle direct deep links (app already installed) LinkForty.onDeepLink((url, data) => { console.log('Deep link opened:', url); if (data) { navigateToContent(data); } }); } setup(); }, []); return (/* your app */); } ``` ##### Track Events ```typescript import { LinkForty } from '@linkforty/mobile-sdk-expo'; // Custom event await LinkForty.trackEvent('add_to_cart', { productId: '123', category: 'electronics', }); // Revenue event await LinkForty.trackRevenue(29.99, 'USD', { productId: '123', productName: 'Wireless Headphones', }); ``` ##### Create Links ```typescript import { LinkForty } from '@linkforty/mobile-sdk-expo'; const result = await LinkForty.createLink({ deepLinkParameters: { route: 'PRODUCT', id: '123' }, title: 'Check out this product', utmParameters: { source: 'app', medium: 'share' }, }); console.log(result.url); // https://go.yourdomain.com/abc123 console.log(result.shortCode); // abc123 console.log(result.linkId); // uuid ``` **Warning:** Link creation requires an API key. The simplified endpoint (`/api/sdk/v1/links`) is only available on LinkForty Cloud, not self-hosted Core. --- #### API Reference ##### Initialization ###### `initialize(config)` Initialize the SDK. Must be called before any other method. ```typescript await LinkForty.initialize(config: LinkFortyConfig): Promise ``` Returns the install attribution response on first launch, or `null` on subsequent launches. | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `baseUrl` | string | Yes | -- | Your LinkForty server URL | | `apiKey` | string | No | -- | API key for link creation and Cloud features | | `appToken` | string | No | -- | Public workspace token (`at_…`). Recommended for Cloud — enables organic-install attribution. Safe to ship in your app bundle. | | `debug` | boolean | No | `false` | Enable verbose logging | | `attributionWindowHours` | number | No | `168` | Attribution window in hours (1--2160) | | `autoTrackNavigation` | boolean \| object | No | off | Auto-emit `screen_view` events from React Navigation. Requires `navigationRef`. See [Automatic Screen Tracking](#automatic-screen-tracking). | | `navigationRef` | ref | No | -- | Your React Navigation container ref. Required when `autoTrackNavigation` is enabled. | ###### `isInitialized` Boolean getter indicating whether the SDK has been initialized. ```typescript if (LinkForty.isInitialized) { /* ... */ } ``` ##### Deep Linking ###### `onDeferredDeepLink(callback)` Register a callback for deferred deep links. If data is already available, the callback fires immediately. ```typescript LinkForty.onDeferredDeepLink(callback: (data: DeepLinkData | null) => void): void ``` ###### `onDeepLink(callback)` Register a callback for direct deep links. The SDK resolves Universal Links / App Links server-side for enriched data. ```typescript LinkForty.onDeepLink(callback: (url: string, data: DeepLinkData | null) => void): void ``` ###### `handleDeepLink(url)` Manually pass a URL for the SDK to process. ```typescript LinkForty.handleDeepLink(url: string): void ``` ##### Event Tracking ###### `trackEvent(name, properties?)` Track a custom in-app event. ```typescript await LinkForty.trackEvent(name: string, properties?: Record): Promise ``` ###### `trackRevenue(amount, currency, properties?)` Track a revenue event with structured amount and currency. ```typescript await LinkForty.trackRevenue( amount: number, currency: string, properties?: Record ): Promise ``` ##### Event Queue ###### `flushEvents()` Manually send all queued events. ```typescript await LinkForty.flushEvents(): Promise ``` ###### `clearEventQueue()` Remove queued events without sending them. ```typescript await LinkForty.clearEventQueue(): Promise ``` ###### `queuedEventCount` Getter returning the number of events waiting to be sent. ```typescript const count = LinkForty.queuedEventCount; ``` ##### Link Creation ###### `createLink(options)` Create a short link programmatically. Requires an API key. ```typescript await LinkForty.createLink(options: CreateLinkOptions): Promise ``` | Option | Type | Required | Description | |--------|------|----------|-------------| | `templateId` | string | No | Template UUID (uses organization's default template if omitted) | | `templateSlug` | string | No | Template slug for URL construction | | `deepLinkParameters` | Record\ | No | In-app routing parameters | | `title` | string | No | Link title | | `description` | string | No | Link description | | `customCode` | string | No | Custom short code | | `utmParameters` | UTMParameters | No | Campaign tracking parameters | | `externalUserId` | string | No | User ID for deduplication and share attribution | ##### User Identity ###### `setExternalUserId(id)` Set the external user ID for share attribution. This ID is automatically attached to all `createLink()` calls unless overridden per-call. Pass `null` to clear. ```typescript LinkForty.setExternalUserId(id: string | null): void ``` ###### `getExternalUserId()` Returns the current external user ID, or `null` if not set. ```typescript LinkForty.getExternalUserId(): string | null ``` ##### Data Access ###### `getInstallId()` ```typescript await LinkForty.getInstallId(): Promise ``` ###### `getInstallData()` ```typescript await LinkForty.getInstallData(): Promise ``` ###### `isFirstLaunch()` ```typescript await LinkForty.isFirstLaunch(): Promise ``` ##### Data Management ###### `clearData()` Wipe all locally stored SDK data. ```typescript await LinkForty.clearData(): Promise ``` ###### `reset()` Return the SDK to an uninitialized state so it can be re-initialized. ```typescript LinkForty.reset(): void ``` --- #### Error Handling All errors are `LinkFortyError` instances with a `.code` property for programmatic handling: ```typescript import { LinkForty, LinkFortyError, LinkFortyErrorCode } from '@linkforty/mobile-sdk-expo'; try { await LinkForty.createLink({ title: 'Test' }); } catch (error) { if (error instanceof LinkFortyError) { switch (error.code) { case LinkFortyErrorCode.MISSING_API_KEY: console.log('API key required for link creation'); break; case LinkFortyErrorCode.NOT_INITIALIZED: console.log('Call initialize() first'); break; case LinkFortyErrorCode.NETWORK_ERROR: console.log('Network unavailable'); break; } } } ``` ##### Error Codes | Code | Trigger | |------|---------| | `NOT_INITIALIZED` | Method called before `initialize()` | | `ALREADY_INITIALIZED` | Duplicate `initialize()` call | | `INVALID_CONFIGURATION` | Invalid config parameters (e.g., non-HTTPS URL, out-of-range attribution window) | | `NETWORK_ERROR` | Failed network request | | `INVALID_RESPONSE` | Non-2xx server response | | `DECODING_ERROR` | Failed JSON parsing | | `INVALID_EVENT_DATA` | Empty event name or negative revenue amount | | `MISSING_API_KEY` | `createLink()` called without an API key | --- #### Offline Resilience Events are automatically queued in AsyncStorage when the network is unavailable: - Maximum queue size: 100 events - Queue persists across app restarts - Events are retried automatically when connectivity is restored - Use `flushEvents()` to manually trigger a send - Use `clearEventQueue()` to discard queued events without sending - Check `queuedEventCount` to monitor queue depth --- #### TypeScript Types ```typescript import { LinkFortySDK, LinkFortyError, LinkFortyErrorCode } from '@linkforty/mobile-sdk-expo'; import type { LinkFortyConfig, DeepLinkData, InstallAttributionResponse, UTMParameters, DeviceFingerprint, CreateLinkOptions, CreateLinkResult, EventRequest, DeferredDeepLinkCallback, DeepLinkCallback, } from '@linkforty/mobile-sdk-expo'; ``` --- #### Configuration Validation The Expo SDK validates configuration at initialization: - **Base URL** must use HTTPS (except `localhost` and `127.0.0.1` for local development) - **Attribution window** must be between 1 and 2160 hours (90 days) - Invalid configuration throws a `LinkFortyError` with code `INVALID_CONFIGURATION` --- #### Automatic Screen Tracking Enable `autoTrackNavigation` to emit a `screen_view` event on every React Navigation transition automatically — no per-screen `trackEvent` calls. Screen views flow through the normal event pipeline and carry the active deep-link attribution context, feeding [App Analytics](https://docs.linkforty.com/guides/analytics/app-analytics/) and the [SDK health](https://docs.linkforty.com/guides/attribution/sdk-health/) signal. It requires a [navigation container ref](https://reactnavigation.org/docs/navigation-container/#ref): ```typescript import { createNavigationContainerRef, NavigationContainer } from '@react-navigation/native'; import { LinkForty } from '@linkforty/mobile-sdk-expo'; export const navigationRef = createNavigationContainerRef(); await LinkForty.initialize({ baseUrl: 'https://go.yourdomain.com', appToken: 'at_your_app_token', autoTrackNavigation: true, // screen names only — privacy-safe default navigationRef, }); {/* ...screens... */} ``` ##### Capturing route params `autoTrackNavigation: true` captures **screen names only**. To capture specific, non-PII params, pass an options object with an explicit allow-list: ```typescript await LinkForty.initialize({ baseUrl: 'https://go.yourdomain.com', navigationRef, autoTrackNavigation: { captureParams: ['productId', 'category'], // only these keys debounceMs: 350, // default 350ms }, }); ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `captureParams` | string[] | `[]` (none) | Allow-list of route param keys to capture. Never list keys that can hold personal data. | | `debounceMs` | number | `350` | Debounce window for rapid transitions, in milliseconds. | ##### What gets sent Each transition emits a `screen_view` event with `screen` (active route name), `previousScreen` (when available), and `params` (only allow-listed keys). Rapid transitions are debounced and duplicates dropped. If `autoTrackNavigation` is enabled without a `navigationRef`, screen tracking is disabled (a warning is logged in `debug` mode). **Tip: Expo Router** Expo Router is built on React Navigation. Use its [navigation container ref](https://reactnavigation.org/docs/navigation-container/#ref) so the SDK can observe route changes. --- #### Self-Hosted Configuration For [self-hosted LinkForty Core](https://docs.linkforty.com/getting-started/self-hosting/), omit the API key: ```typescript await LinkForty.initialize({ baseUrl: 'https://links.yourcompany.com', debug: false, }); ``` --- #### Troubleshooting ##### Deep links not working in Expo Go Expo Go has limitations with Universal Links / App Links. For full deep linking support, use a [development build](https://docs.expo.dev/develop/development-builds/introduction/) or EAS Build. ##### `ALREADY_INITIALIZED` error The SDK guards against double initialization. If you need to reinitialize (e.g., after changing configuration), call `reset()` first: ```typescript LinkForty.reset(); await LinkForty.initialize(newConfig); ``` ##### Events not appearing in analytics 1. Check `queuedEventCount` -- events may be queued due to network issues 2. Call `flushEvents()` to manually send queued events 3. Ensure the SDK has a valid install ID (check with `getInstallId()`) --- #### Next Steps - [Create links](https://docs.linkforty.com/guides/links/creating-links/) in the dashboard - [View analytics](https://docs.linkforty.com/guides/analytics/dashboard/) for your app - [Configure attribution windows](https://docs.linkforty.com/guides/attribution/attribution-windows/) - [SDK Specification](https://docs.linkforty.com/sdks/sdk-specification/) -- full feature reference #### Resources - [GitHub](https://github.com/linkforty/mobile-sdk-expo) - [Report an issue](https://github.com/linkforty/mobile-sdk-expo/issues) --- ## iOS SDK Source: https://docs.linkforty.com/sdks/ios/ Native iOS SDK for deep linking, mobile attribution, and conversion tracking. **Tip: Faster setup with AI** Already using Claude Code, Cursor, or Claude Desktop? The [LinkForty MCP server](https://docs.linkforty.com/guides/advanced/mcp-server/) can scaffold your SDK integration automatically. Just ask: *"Help me add the LinkForty SDK to my iOS app."* #### Features - **Deferred deep linking** -- route new users to specific content after install - **Direct deep linking** -- handle Universal Links and custom URL schemes - **Install attribution** -- probabilistic fingerprint matching - **Event tracking** -- log in-app events tied to attribution data - **Revenue tracking** -- structured purchase tracking with dedicated method - **Offline event queue** -- events persist across app restarts and retry automatically - **Programmatic link creation** -- create short links from your app - **Zero external dependencies** - **Privacy-first** -- no IDFA collection, includes Privacy Manifest #### Requirements - iOS 16.0+ - Xcode 15.0+ - Swift 5.9+ #### Installation ##### Swift Package Manager (recommended) In Xcode: **File > Add Package Dependencies**, then enter: ``` https://github.com/LinkForty/mobile-sdk-ios.git ``` Or add to your `Package.swift`: ```swift dependencies: [ .package(url: "https://github.com/LinkForty/mobile-sdk-ios.git", from: "1.4.0") ] ``` ##### CocoaPods ```ruby pod 'LinkFortySDK', '~> 1.0' ``` ##### Carthage ``` github "LinkForty/mobile-sdk-ios" ~> 1.0 ``` --- #### Quick Start ##### Initialize the SDK Initialize once at app launch, typically in your `AppDelegate` or `@main` App struct: ```swift import LinkFortySDK @main struct MyApp: App { init() { Task { let config = LinkFortyConfig( baseURL: URL(string: "https://go.yourdomain.com")!, apiKey: "your-api-key", // optional -- required for link creation appToken: "at_your_app_token", // recommended for Cloud -- enables organic-install attribution debug: true, attributionWindowHours: 168 // 7 days (default) ) try await LinkForty.shared.initialize(config: config) } } var body: some Scene { WindowGroup { ContentView() } } } ``` ##### Handle Deferred Deep Links ```swift LinkForty.shared.onDeferredDeepLink { deepLinkData in if let data = deepLinkData { print("Attributed install: \(data.shortCode)") // Navigate to content based on custom parameters if let productId = data.customParameters?["productId"] { navigateToProduct(productId) } } else { print("Organic install") } } ``` ##### Handle Direct Deep Links Pass incoming URLs to the SDK from your `AppDelegate` or SwiftUI `onOpenURL`: ```swift // SwiftUI .onOpenURL { url in LinkForty.shared.handleDeepLink(url: url) } // UIKit AppDelegate func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any]) -> Bool { LinkForty.shared.handleDeepLink(url: url) return true } ``` Register a callback to receive the parsed data: ```swift LinkForty.shared.onDeepLink { url, deepLinkData in print("Deep link opened: \(url)") if let data = deepLinkData { if let route = data.customParameters?["route"] { navigateToRoute(route) } } } ``` ##### Track Events ```swift // Custom event try await LinkForty.shared.trackEvent( name: "add_to_cart", properties: ["productId": "123", "category": "electronics"] ) // Revenue event try await LinkForty.shared.trackRevenue( amount: 29.99, currency: "USD", properties: ["productId": "123"] ) ``` Every event is automatically credited to the deep link that most recently opened the app (last-click attribution), along with an app-open session — so analytics can show what users do *after* clicking a link. Events with no preceding deep-link open are reported as organic. No extra code is required. ##### Track Screen Views Reporting screen views lets analytics build a per-link screen-flow funnel — which screens users reach after opening a deep link. Each `screen_view` carries the same last-click attribution as your other events. **SwiftUI** — add the `.linkfortyScreen(_:)` modifier to a screen: ```swift ProductView() .linkfortyScreen("ProductDetail") ``` **UIKit** — call from `viewDidAppear`: ```swift override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) Task { try? await LinkForty.shared.trackScreenView(name: "ProductDetail") } } ``` You mark the screens you care about (there is no navigation swizzling), which keeps screen names meaningful; the SDK records the previous screen automatically so transitions appear in the funnel. ##### Create Links ```swift let result = try await LinkForty.shared.createLink(options: CreateLinkOptions( deepLinkParameters: ["route": "PRODUCT", "id": "123"], title: "Check out this product", utmParameters: UTMParameters(source: "app", medium: "share") )) print(result.url) // https://go.yourdomain.com/abc123 print(result.shortCode) // abc123 ``` **Warning:** Link creation requires an API key. The simplified endpoint (`/api/sdk/v1/links`) is only available on LinkForty Cloud, not self-hosted Core. --- #### Universal Links Setup 1. In Xcode, enable the **Associated Domains** capability 2. Add: `applinks:go.yourdomain.com` 3. Your LinkForty server automatically hosts the Apple App Site Association file at `/.well-known/apple-app-site-association` Example AASA format (served automatically by LinkForty): ```json { "applinks": { "apps": [], "details": [{ "appID": "TEAM_ID.com.yourcompany.yourapp", "paths": ["*"] }] } } ``` --- #### API Reference ##### Initialization | Method | Description | |--------|-------------| | `initialize(config:)` | Initialize the SDK with configuration | ##### Deep Linking | Method | Description | |--------|-------------| | `onDeferredDeepLink(_:)` | Register callback for install attribution | | `onDeepLink(_:)` | Register callback for direct deep links | | `handleDeepLink(url:)` | Pass an incoming URL to the SDK | ##### Event Tracking | Method | Description | |--------|-------------| | `trackEvent(name:properties:)` | Track a custom in-app event | | `trackRevenue(amount:currency:properties:)` | Track a revenue event | | `trackScreenView(name:properties:)` | Report a screen view (for screen-flow funnels) | SwiftUI apps can use the `.linkfortyScreen("Name")` view modifier instead of calling `trackScreenView` manually. ##### Link Creation | Method | Description | |--------|-------------| | `createLink(options:)` | Create a short link programmatically | ##### User Identity | Method | Description | |--------|-------------| | `setExternalUserId(_:)` | Set external user ID for share attribution (pass `nil` to clear) | | `getExternalUserId()` | Get the current external user ID | ##### Data Access | Method | Description | |--------|-------------| | `getInstallData()` | Retrieve cached attribution data | | `getInstallId()` | Get the server-assigned install UUID | ##### Event Queue | Method | Description | |--------|-------------| | `flushEvents()` | Manually send all queued events | | `clearEventQueue()` | Remove queued events without sending | | `queuedEventCount` | Number of events waiting to be sent | ##### Data Management | Method | Description | |--------|-------------| | `clearData()` | Wipe all locally stored SDK data | | `reset()` | Return SDK to uninitialized state | --- #### Offline Resilience Events are automatically queued when the network is unavailable: - Maximum queue size: 100 events - Queue persists across app restarts - Events are retried automatically when connectivity is restored - Use `flushEvents()` to manually trigger a send - Use `clearEventQueue()` to discard queued events --- #### Privacy The iOS SDK is designed with privacy in mind: - **No IDFA collection** -- attribution uses probabilistic fingerprinting only - **Privacy Manifest included** -- declares data collection practices for App Store compliance - **Minimal data collection** -- timezone, language, screen resolution, iOS version, app version - **HTTPS enforced** -- all communication uses HTTPS (except localhost for development) --- #### Self-Hosted Configuration For [self-hosted LinkForty Core](https://docs.linkforty.com/getting-started/self-hosting/), omit the API key: ```swift let config = LinkFortyConfig( baseURL: URL(string: "https://links.yourcompany.com")!, apiKey: nil, debug: false ) try await LinkForty.shared.initialize(config: config) ``` --- #### Troubleshooting ##### Universal Links not opening the app 1. Verify Associated Domains capability is enabled with the correct domain 2. Check that the AASA file is accessible at `https://go.yourdomain.com/.well-known/apple-app-site-association` 3. Long-press the link in Safari -- if "Open in App" appears, Universal Links are configured 4. Apple caches AASA files; changes can take time to propagate ##### Deferred deep link not firing 1. Check debug logs (`debug: true`) for install reporting errors 2. Ensure the attribution window hasn't expired 3. Fingerprint matching depends on network conditions -- VPNs and privacy relays reduce accuracy ##### Build errors with SPM Ensure your project targets iOS 16.0+ and uses Xcode 15+. --- #### Next Steps - [Create links](https://docs.linkforty.com/guides/links/creating-links/) in the dashboard - [View analytics](https://docs.linkforty.com/guides/analytics/dashboard/) for your app - [Configure attribution windows](https://docs.linkforty.com/guides/attribution/attribution-windows/) - [SDK Specification](https://docs.linkforty.com/sdks/sdk-specification/) -- full feature reference #### Resources - [GitHub](https://github.com/linkforty/mobile-sdk-ios) - [Report an issue](https://github.com/linkforty/mobile-sdk-ios/issues) --- ## Android SDK Source: https://docs.linkforty.com/sdks/android/ Native Android SDK for deep linking, mobile attribution, and conversion tracking. **Tip: Faster setup with AI** Already using Claude Code, Cursor, or Claude Desktop? The [LinkForty MCP server](https://docs.linkforty.com/guides/advanced/mcp-server/) can scaffold your SDK integration automatically. Just ask: *"Help me add the LinkForty SDK to my Android app."* #### Features - **Deferred deep linking** -- route new users to specific content after install - **Direct deep linking** -- handle App Links and custom URL schemes - **Install attribution** -- probabilistic fingerprint matching - **Event tracking** -- log in-app events tied to attribution data - **Revenue tracking** -- structured purchase tracking with BigDecimal precision - **Offline event queue** -- events persist across app restarts and retry automatically - **Programmatic link creation** -- create short links from your app - **Privacy-first** -- no GAID collection #### Requirements - Android API 26+ (Android 8.0 Oreo) - Kotlin 1.9+ - JDK 17 #### Installation ##### Gradle (Kotlin DSL) ```kotlin dependencies { implementation("com.linkforty:sdk:1.3.0") } ``` ##### Gradle (Groovy) ```groovy dependencies { implementation 'com.linkforty:sdk:1.3.0' } ``` --- #### Quick Start ##### Initialize the SDK Initialize once in your `Application` class or main `Activity`: ```kotlin import com.linkforty.sdk.LinkForty import com.linkforty.sdk.LinkFortyConfig class MyApplication : Application() { override fun onCreate() { super.onCreate() val config = LinkFortyConfig( baseURL = "https://go.yourdomain.com", apiKey = "your-api-key", // optional -- required for link creation appToken = "at_your_app_token", // recommended for Cloud -- enables organic-install attribution debug = true, attributionWindowHours = 168 // 7 days (default) ) LinkForty.initialize(this, config) } } ``` ##### Handle Deferred Deep Links ```kotlin LinkForty.shared.onDeferredDeepLink { deepLinkData -> if (deepLinkData != null) { println("Attributed install: ${deepLinkData.shortCode}") val productId = deepLinkData.customParameters?.get("productId") if (productId != null) { navigateToProduct(productId) } } else { println("Organic install") } } ``` ##### Handle Direct Deep Links Pass incoming intents to the SDK from your `Activity`: ```kotlin class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) handleIntent(intent) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) handleIntent(intent) } private fun handleIntent(intent: Intent) { intent.data?.let { uri -> LinkForty.shared.handleDeepLink(uri) } } } ``` Register a callback to receive parsed data: ```kotlin LinkForty.shared.onDeepLink { url, deepLinkData -> println("Deep link opened: $url") deepLinkData?.let { data -> val route = data.customParameters?.get("route") if (route != null) { navigateToRoute(route) } } } ``` ##### Track Events ```kotlin // Custom event LinkForty.shared.trackEvent( name = "add_to_cart", properties = mapOf("productId" to "123", "category" to "electronics") ) // Revenue event with BigDecimal precision LinkForty.shared.trackRevenue( amount = BigDecimal("29.99"), currency = "USD", properties = mapOf("productId" to "123") ) ``` Every event is automatically credited to the deep link that most recently opened the app (last-click attribution), along with an app-open session — so analytics can show what users do *after* clicking a link. Events with no preceding deep-link open are reported as organic. No extra code is required. ##### Track Screen Views Reporting screen views lets analytics build a per-link screen-flow funnel. Each `screen_view` carries the same last-click attribution as your other events. **Automatic (Jetpack Navigation)** — attach `LinkFortyNavObserver` to your `NavController`; each destination's route (Compose) or label (XML nav graph) is reported as it appears: ```kotlin import com.linkforty.sdk.navigation.LinkFortyNavObserver navController.addOnDestinationChangedListener(LinkFortyNavObserver()) ``` `LinkFortyNavObserver` requires `androidx.navigation` (which your app already has if it uses Jetpack Navigation); the SDK depends on it only as `compileOnly`. **Manual** — call it yourself (e.g. for screens not driven by a `NavController`): ```kotlin LinkForty.shared.trackScreenView("ProductDetail") ``` ##### Create Links ```kotlin val result = LinkForty.shared.createLink( CreateLinkOptions( deepLinkParameters = mapOf("route" to "PRODUCT", "id" to "123"), title = "Check out this product", utmParameters = UTMParameters(source = "app", medium = "share") ) ) println(result.url) // https://go.yourdomain.com/abc123 println(result.shortCode) // abc123 ``` **Warning:** Link creation requires an API key. The simplified endpoint (`/api/sdk/v1/links`) is only available on LinkForty Cloud, not self-hosted Core. --- #### App Links Setup ##### AndroidManifest.xml Add an intent filter to your main activity: ```xml ``` Your LinkForty server automatically hosts the Digital Asset Links file at `/.well-known/assetlinks.json`. ##### Important: onNewIntent Override `onNewIntent()` in your `Activity` to handle links when the app is already running. Without this, tapping a link while the app is in the background won't deliver the new intent data. --- #### API Reference ##### Initialization | Method | Description | |--------|-------------| | `LinkForty.initialize(context, config)` | Initialize the SDK with context and configuration | ##### Deep Linking | Method | Description | |--------|-------------| | `onDeferredDeepLink(callback)` | Register callback for install attribution | | `onDeepLink(callback)` | Register callback for direct deep links | | `handleDeepLink(uri)` | Pass an incoming URI to the SDK | ##### Event Tracking | Method | Description | |--------|-------------| | `trackEvent(name, properties)` | Track a custom in-app event | | `trackRevenue(amount, currency, properties)` | Track a revenue event (BigDecimal) | | `trackScreenView(name, properties)` | Report a screen view (for screen-flow funnels) | For automatic screen tracking with Jetpack Navigation, attach `LinkFortyNavObserver` to your `NavController` instead of calling `trackScreenView` per screen. ##### Link Creation | Method | Description | |--------|-------------| | `createLink(options)` | Create a short link programmatically | ##### User Identity | Method | Description | |--------|-------------| | `setExternalUserId(id)` | Set external user ID for share attribution (pass `null` to clear) | | `getExternalUserId()` | Get the current external user ID | ##### Data Access | Method | Description | |--------|-------------| | `getInstallData()` | Retrieve cached attribution data | | `getInstallId()` | Get the server-assigned install UUID | ##### Data Management | Method | Description | |--------|-------------| | `clearData()` | Wipe all locally stored SDK data | | `reset()` | Return SDK to uninitialized state | --- #### Offline Resilience Events are automatically queued when the network is unavailable: - Maximum queue size: 100 events - Queue persists across app restarts - Events are retried automatically when connectivity is restored --- #### Privacy The Android SDK is designed with privacy in mind: - **No GAID collection** -- attribution uses probabilistic fingerprinting only - **Minimal data collection** -- timezone, language, screen resolution, Android version, app version, User-Agent - **HTTPS enforced** -- all communication uses HTTPS (except localhost for development) --- #### Self-Hosted Configuration For [self-hosted LinkForty Core](https://docs.linkforty.com/getting-started/self-hosting/), omit the API key: ```kotlin val config = LinkFortyConfig( baseURL = "https://links.yourcompany.com", apiKey = null, debug = false ) LinkForty.initialize(this, config) ``` --- #### Troubleshooting ##### App Links not opening the app 1. Verify `android:autoVerify="true"` is set on the intent filter 2. Check that the Digital Asset Links file is accessible at `https://go.yourdomain.com/.well-known/assetlinks.json` 3. Test with: `adb shell am start -a android.intent.action.VIEW -d "https://go.yourdomain.com/test"` 4. Verify app is set as default handler in Settings > Apps > Default apps ##### Deferred deep link not firing 1. Check Logcat output (filter by "LinkForty") for install reporting errors 2. Ensure the attribution window hasn't expired 3. Fingerprint matching depends on network conditions -- VPNs reduce accuracy ##### ProGuard / R8 If you're using code shrinking, the SDK should work without additional rules. If you encounter issues, add: ```proguard -keep class com.linkforty.sdk.** { *; } ``` --- #### Next Steps - [Create links](https://docs.linkforty.com/guides/links/creating-links/) in the dashboard - [View analytics](https://docs.linkforty.com/guides/analytics/dashboard/) for your app - [Configure attribution windows](https://docs.linkforty.com/guides/attribution/attribution-windows/) - [SDK Specification](https://docs.linkforty.com/sdks/sdk-specification/) -- full feature reference #### Resources - [GitHub](https://github.com/linkforty/mobile-sdk-android) - [Report an issue](https://github.com/linkforty/mobile-sdk-android/issues) --- ## Flutter SDK Source: https://docs.linkforty.com/sdks/flutter/ Native Flutter SDK for deep linking, mobile attribution, and conversion tracking. Pure Dart implementation with iOS and Android support. **Tip: Faster setup with AI** Already using Claude Code, Cursor, or Claude Desktop? The [LinkForty MCP server](https://docs.linkforty.com/guides/advanced/mcp-server/) can scaffold your SDK integration automatically. Just ask: *"Help me add the LinkForty SDK to my Flutter app."* #### Features - **Deferred deep linking** -- route new users to specific content after install - **Direct deep linking** -- handle Universal Links, App Links, and custom URL schemes - **Install attribution** -- probabilistic fingerprint matching - **Event tracking** -- log in-app events tied to attribution data - **Revenue tracking** -- structured purchase tracking with dedicated method - **Offline event queue** -- events persist across app restarts and retry automatically - **Programmatic link creation** -- create short links from your app - **Pure Dart** -- 100% Dart, standard `Future`/`Stream` APIs - **Privacy-first** -- no IDFA/GAID collection #### Requirements - Flutter 3.10+ - Dart 3.0+ - iOS 12.0+ - Android API 21+ #### Installation Add to your `pubspec.yaml`: ```yaml dependencies: linkforty_flutter: ^0.2.0 ``` Or run: ```bash flutter pub add linkforty_flutter ``` --- #### Quick Start ##### Initialize the SDK Initialize once at app startup, typically in `main.dart`: ```dart import 'package:linkforty_flutter/link_forty.dart'; import 'package:linkforty_flutter/models/link_forty_config.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); try { final config = LinkFortyConfig( baseURL: Uri.parse('https://go.yourdomain.com'), apiKey: 'your-api-key', // optional -- required for link creation debug: true, attributionWindowHours: 168, // 7 days (default) ); final response = await LinkForty.initialize(config: config); print('LinkForty initialized. Install ID: ${response.installId}'); } catch (e) { print('LinkForty initialization failed: $e'); } runApp(const MyApp()); } ``` ##### Handle Deferred Deep Links ```dart LinkForty.instance.onDeferredDeepLink((deepLinkData) { if (deepLinkData != null) { print('Attributed install: ${deepLinkData.shortCode}'); final productId = deepLinkData.customParameters?['productId']; if (productId != null) { navigatorKey.currentState?.pushNamed('/product', arguments: productId); } } else { print('Organic install'); } }); ``` ##### Handle Direct Deep Links The SDK uses `app_links` internally to handle incoming links. Register a callback: ```dart LinkForty.instance.onDeepLink((uri, deepLinkData) { print('Deep link opened: $uri'); if (deepLinkData != null) { if (deepLinkData.deepLinkPath != null) { navigatorKey.currentState?.pushNamed(deepLinkData.deepLinkPath!); } } }); ``` **Tip: Server-side resolution** Deep links are automatically resolved via the server for enriched data including `deepLinkPath`, `appScheme`, and `linkId`. If the server is unreachable, the SDK falls back to local URL parsing. ##### Track Events ```dart // Custom event await LinkForty.instance.trackEvent('add_to_cart', { 'product_id': '123', 'category': 'electronics', }); // Revenue event await LinkForty.instance.trackRevenue( amount: 29.99, currency: 'USD', properties: {'product_id': '123'}, ); ``` Every event is automatically credited to the deep link that most recently opened the app (last-click attribution), along with an app-open session — so analytics can show what users do *after* clicking a link. Events with no preceding deep-link open are reported as organic. No extra code is required. ##### Track Screen Views Reporting screen views lets analytics build a per-link screen-flow funnel. Each `screen_view` carries the same last-click attribution as your other events. **Automatic** — add `LinkFortyNavigatorObserver` to your app's `navigatorObservers`; named routes are reported as they appear: ```dart import 'package:linkforty_flutter/linkforty_flutter.dart'; MaterialApp( navigatorObservers: [LinkFortyNavigatorObserver()], // ... ); ``` **Manual** — call it yourself (e.g. for screens not driven by named routes): ```dart await LinkForty.instance.trackScreenView('ProductDetail'); ``` ##### Create Links ```dart import 'package:linkforty_flutter/models/create_link_options.dart'; import 'package:linkforty_flutter/models/utm_parameters.dart'; final result = await LinkForty.instance.createLink( CreateLinkOptions( templateId: 'your-template-id', deepLinkParameters: {'route': 'PRODUCT', 'id': '123'}, title: 'Check out this product', utmParameters: UTMParameters(source: 'app', campaign: 'share'), ), ); print(result.url); // https://go.yourdomain.com/tmpl/abc123 print(result.shortCode); // abc123 print(result.linkId); // uuid ``` **Warning:** Link creation requires an API key and a template ID. The link creation endpoint is only available on LinkForty Cloud, not self-hosted Core. --- #### Platform Setup ##### Android Add intent filters to your `AndroidManifest.xml`: ```xml ``` Your LinkForty server automatically hosts the Digital Asset Links file at `/.well-known/assetlinks.json`. ##### iOS 1. In Xcode, enable the **Associated Domains** capability 2. Add: `applinks:go.yourdomain.com` 3. Optionally add your custom URL scheme under **URL Types** in `Info.plist` Your LinkForty server automatically hosts the Apple App Site Association file at `/.well-known/apple-app-site-association`. --- #### API Reference ##### Initialization ###### `LinkForty.initialize(config:)` Initialize the SDK. Must be called before any other method. Returns an `InstallResponse` with attribution results. ```dart static Future initialize({ required LinkFortyConfig config, int attributionWindowHours = 168, String? deviceId, }) ``` | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `config` | `LinkFortyConfig` | Yes | -- | SDK configuration | | `attributionWindowHours` | `int` | No | `168` | Attribution window in hours (1--2160) | | `deviceId` | `String?` | No | `null` | Optional device ID for high-confidence matching | **`LinkFortyConfig` parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | `baseURL` | `Uri` | Yes | -- | Your LinkForty server URL | | `apiKey` | `String?` | No | `null` | API key for link creation and Cloud features | | `debug` | `bool` | No | `false` | Enable verbose logging | | `attributionWindowHours` | `int` | No | `168` | Attribution window in hours (1--2160) | ###### `LinkForty.instance` Returns the shared SDK instance. Throws `NotInitializedError` if not initialized. ###### `LinkForty.instanceOrNull` Returns the shared SDK instance, or `null` if not initialized. ##### Deep Linking ###### `onDeferredDeepLink(callback)` Register a callback for deferred deep links. If data is already available, the callback fires immediately. ```dart void onDeferredDeepLink(DeferredDeepLinkCallback callback) ``` ###### `onDeepLink(callback)` Register a callback for direct deep links. The SDK resolves Universal Links / App Links server-side for enriched data. ```dart void onDeepLink(DeepLinkCallback callback) ``` ###### `handleDeepLink(uri)` Manually pass a URI for the SDK to process. ```dart Future handleDeepLink(Uri uri) ``` ##### Event Tracking ###### `trackEvent(name, properties?)` Track a custom in-app event. ```dart Future trackEvent(String name, [Map? properties]) ``` ###### `trackRevenue(amount:, currency:, properties:)` Track a revenue event with structured amount and currency. ```dart Future trackRevenue({ required double amount, required String currency, Map? properties, }) ``` ###### `trackScreenView(name, properties?)` Report a screen view for screen-flow funnels. For automatic tracking, add `LinkFortyNavigatorObserver` to your app's `navigatorObservers` instead. ```dart Future trackScreenView(String name, [Map? properties]) ``` ##### Event Queue ###### `flushEvents()` Manually send all queued events. ```dart Future flushEvents() ``` ###### `clearEventQueue()` Remove queued events without sending them. ```dart void clearEventQueue() ``` ###### `queuedEventCount` Getter returning the number of events waiting to be sent. ```dart int get queuedEventCount ``` ##### Link Creation ###### `createLink(options)` Create a short link programmatically. Requires an API key and template ID. ```dart Future createLink(CreateLinkOptions options) ``` | Option | Type | Required | Description | |--------|------|----------|-------------| | `templateId` | `String?` | No | Template UUID (uses organization's default template if omitted) | | `templateSlug` | `String?` | No | Template slug for URL construction | | `deepLinkParameters` | `Map?` | No | In-app routing parameters | | `title` | `String?` | No | Link title | | `description` | `String?` | No | Link description | | `customCode` | `String?` | No | Custom short code | | `utmParameters` | `UTMParameters?` | No | Campaign tracking parameters | | `externalUserId` | `String?` | No | User ID for deduplication and share attribution | ##### Data Access ###### `getInstallId()` ```dart String? getInstallId() ``` ###### `getInstallData()` ```dart DeepLinkData? getInstallData() ``` ###### `isFirstLaunch()` ```dart bool isFirstLaunch() ``` ##### Data Management ###### `clearData()` Wipe all locally stored SDK data. ```dart Future clearData() ``` ###### `reset()` Return the SDK to an uninitialized state so it can be re-initialized. ```dart void reset() ``` --- #### Error Handling All errors extend the `LinkFortyError` base class: ```dart import 'package:linkforty_flutter/errors/link_forty_error.dart'; try { await LinkForty.instance.createLink(CreateLinkOptions(title: 'Test')); } on MissingApiKeyError { print('API key required for link creation'); } on MissingTemplateIdError { print('Template ID required for link creation'); } on NotInitializedError { print('Call initialize() first'); } on NetworkError catch (e) { print('Network unavailable: $e'); } on LinkFortyError catch (e) { print('SDK error: $e'); } ``` ##### Error Types | Error | Trigger | |-------|---------| | `NotInitializedError` | Method called before `initialize()` | | `AlreadyInitializedError` | Duplicate `initialize()` call | | `InvalidConfigurationError` | Invalid config (e.g., non-HTTPS URL, out-of-range attribution window) | | `NetworkError` | Failed network request | | `InvalidResponseError` | Non-2xx server response | | `DecodingError` | Failed JSON parsing | | `EncodingError` | Failed request encoding | | `InvalidEventDataError` | Invalid event data | | `InvalidDeepLinkUrlError` | Malformed deep link URL | | `MissingApiKeyError` | `createLink()` called without an API key | | `MissingTemplateIdError` | `createLink()` called without a template ID | --- #### Offline Resilience Events are automatically queued when the network is unavailable: - Queue persists across app restarts - Events are retried automatically when connectivity is restored - Use `flushEvents()` to manually trigger a send - Use `clearEventQueue()` to discard queued events without sending - Check `queuedEventCount` to monitor queue depth --- #### Privacy The Flutter SDK is designed with privacy in mind: - **No IDFA/GAID collection** -- attribution uses probabilistic fingerprinting only - **Minimal data collection** -- timezone, language, screen resolution, OS version, app version - **User control** -- `clearData()` for GDPR/CCPA compliance - **HTTPS enforced** -- all communication uses HTTPS (except localhost and `10.0.2.2` for development) --- #### Configuration Validation The SDK validates configuration at initialization: - **Base URL** must use HTTPS (except `localhost`, `127.0.0.1`, `0.0.0.0`, and `10.0.2.2` for local development) - **Attribution window** must be between 1 and 2160 hours (90 days) - Invalid configuration throws `InvalidConfigurationError` --- #### Self-Hosted Configuration For [self-hosted LinkForty Core](https://docs.linkforty.com/getting-started/self-hosting/), omit the API key: ```dart final config = LinkFortyConfig( baseURL: Uri.parse('https://links.yourcompany.com'), debug: false, ); await LinkForty.initialize(config: config); ``` --- #### Troubleshooting ##### Universal Links / App Links not opening the app **iOS:** 1. Verify Associated Domains capability is enabled with the correct domain 2. Check that the AASA file is accessible at `https://go.yourdomain.com/.well-known/apple-app-site-association` 3. Apple caches AASA files; changes can take time to propagate **Android:** 1. Verify `android:autoVerify="true"` in your intent filter 2. Check that `assetlinks.json` is accessible at `https://go.yourdomain.com/.well-known/assetlinks.json` 3. Verify SHA-256 fingerprint matches your keystore ##### Deferred deep link not firing 1. Check debug logs (`debug: true`) for install reporting errors 2. Ensure the attribution window hasn't expired 3. Fingerprint matching depends on network conditions -- VPNs and privacy relays reduce accuracy ##### `AlreadyInitializedError` The SDK guards against double initialization. If you need to reinitialize, call `reset()` first: ```dart LinkForty.instance.reset(); await LinkForty.initialize(config: newConfig); ``` ##### Events not appearing in analytics 1. Check `queuedEventCount` -- events may be queued due to network issues 2. Call `flushEvents()` to manually send queued events 3. Ensure the SDK has a valid install ID (check with `getInstallId()`) --- #### Next Steps - [Create links](https://docs.linkforty.com/guides/links/creating-links/) in the dashboard - [View analytics](https://docs.linkforty.com/guides/analytics/dashboard/) for your app - [Configure attribution windows](https://docs.linkforty.com/guides/attribution/attribution-windows/) - [SDK Specification](https://docs.linkforty.com/sdks/sdk-specification/) -- full feature reference #### Resources - [GitHub](https://github.com/linkforty/mobile-sdk-flutter) - [pub.dev](https://pub.dev/packages/linkforty_flutter) - [Report an issue](https://github.com/linkforty/mobile-sdk-flutter/issues) --- # Web ## Web Pixel Source: https://docs.linkforty.com/sdks/web-pixel/ Track web visits, engagement, and conversions from your LinkForty links with a single ` ``` Paste it into the `` of every page you want to track. That's the entire installation — page views and engagement are tracked automatically. **Tip: Find your site key** Your **site key** (`ws_…`) lives under **Workspace Settings → Web Pixel**. It is public and safe to ship in your HTML. See [Site key](#site-key) for rotation. #### Configuration attributes All configuration is read from `data-*` attributes on the script tag: | Attribute | Required | Default | Description | |-----------|----------|---------|-------------| | `data-workspace` | **Yes** | — | Your public workspace site key (`ws_<32 hex chars>`). Routes events to your workspace. | | `data-endpoint` | No | `https://api.linkforty.com/api/pixel/v1/event` | Override the ingestion endpoint (e.g. when routing through a custom domain). | | `data-spa` | No | `true` | Single-page-app route tracking. Set `"false"` to disable and send only the initial page view. | | `data-autocapture` | No | `false` | Set `"true"` to auto-track outbound clicks, downloads, and form submits. | | `data-visitors` | No | `false` | Set `"true"` to assign a persistent visitor ID for new-vs-returning analytics. **Consent-sensitive** — see [Visitor ID](#visitor-id-opt-in). | **Example with everything enabled:** ```html ``` #### Tracking conversions A conversion is any meaningful action you want to attribute back to a link — a signup, a purchase, a demo request. There are two ways to fire one. ##### Declarative (no JavaScript) Add `data-lf-event` to any clickable element. When the element (or anything inside it) is clicked, the pixel fires a conversion with that name. Add `data-lf-value` for a numeric value such as revenue. ```html ``` | Attribute | Description | |-----------|-------------| | `data-lf-event` | Conversion event name (max 255 chars). Fired on click. | | `data-lf-value` | Optional numeric value (e.g. revenue). Non-numeric values are ignored. | ##### Programmatic Call `window.LinkForty.track()` from your own code — for conversions that happen after an async step (payment confirmation, server response): ```js window.LinkForty.track('checkout_completed', { revenue: 49.0, plan: 'pro' }); ``` ```ts window.LinkForty.track(eventName: string, properties?: Record): void ``` - `eventName` — the conversion name (max 255 chars). - `properties` — optional flat object of custom data. Values may be primitives or arrays; objects are flattened out. Limited to 30 keys, 512 characters per string. Put revenue in a `revenue` property to have it counted in [Web Analytics goals & revenue](https://docs.linkforty.com/guides/analytics/web-analytics/#goals--revenue). ###### Tracking before the pixel loads Because the script is `async`, it may not be ready when your code runs. Queue calls on `window.LinkForty.q` and the pixel replays them on load: ```js window.LinkForty = window.LinkForty || { q: [] }; window.LinkForty.q.push(['signup', { plan: 'pro' }]); ``` #### JavaScript API The pixel exposes a small global on `window.LinkForty`: | Member | Type | Description | |--------|------|-------------| | `track(name, properties?)` | function | Fire a conversion event imperatively. | | `version` | string | The loaded pixel version (e.g. `"1.2.3"`). | | `q` | array | Pre-load queue — push `[name, properties]` tuples here before the pixel loads. | #### Event types Every event the pixel sends is one of three types: | Type | When it fires | Key data | |------|---------------|----------| | `page_view` | Initial load and each SPA route change (debounced, de-duplicated by URL) | page title, language, URL, referrer, UTM | | `engagement` | Once when the tab is hidden or the page unloads | time on page, scroll depth, bounce | | `conversion` | `data-lf-event` clicks, `track()` calls, and autocapture | event name, value, custom properties | All events also carry the page URL (query string and hash removed), the referrer (likewise trimmed), parsed UTM parameters, and the session/visitor IDs when available. ##### Transport Events are sent with `navigator.sendBeacon()` (falling back to `fetch` with `keepalive`) as a `POST` to the ingestion endpoint with `Content-Type: text/plain`. This keeps the request CORS-safelisted (no preflight) and lets it complete even as the page unloads. The pixel adds no measurable load to your page and ships in well under 10 KB gzipped. #### SPA route tracking For single-page apps (React, Vue, Next.js, SvelteKit, etc.), the pixel automatically tracks client-side navigation. It patches the History API (`pushState`/`replaceState`) and listens for `popstate` (back/forward), then fires a `page_view` for each new route. Rapid transitions are debounced and duplicate URLs are ignored; hash-only changes are not counted as new views. **Session-scoped attribution (last-click).** The UTM parameters on the page where a visitor *lands* are captured as the session origin and stored per-tab in `sessionStorage`. As the visitor navigates to UTM-less routes within your app, conversions still attribute to that originating link. If a later route carries new UTM parameters, it supersedes the origin (last-click wins). Set `data-spa="false"` to turn this off — the pixel will then send only the initial page view. #### Autocapture Set `data-autocapture="true"` to track common interactions without writing any code. Three interaction types are captured, each sent as a `conversion`: | Event name | Trigger | Properties | |------------|---------|------------| | `outbound_click` | A link to a different host (http/https) | `href`, `host` | | `file_download` | A link with a `download` attribute or a known file extension (pdf, zip, dmg, csv, docx, mp4, …) | `href`, `ext` | | `form_submit` | Any `
` submission | `formId`, `formName`, `action` (when present) | Autocapture is off by default. Your own `data-lf-event` and `track()` conversions work the same whether or not autocapture is enabled. #### Visitor ID (opt-in) By default the pixel uses an in-memory, per-tab **session ID** only — there is no durable identifier. Set `data-visitors="true"` to additionally assign a persistent **visitor ID** (`v_…`) stored in `localStorage`. This powers new-vs-returning visitor analytics. **Warning: Consent required** A persistent visitor ID is the one durable identifier the pixel can create. Only enable `data-visitors="true"` where you have the appropriate user consent (GDPR/CCPA). If `localStorage` is unavailable (private browsing, blocked), no visitor ID is sent and tracking continues without it. #### Privacy The pixel is built to be privacy-respecting by default: - **Do Not Track / GPC** — if `navigator.doNotTrack` is `"1"`/`"yes"`, or `navigator.globalPrivacyControl` is `true`, the pixel disables itself. `track()` becomes a no-op and no events are sent. - **No raw IP storage** — the ingestion endpoint uses the request IP only to derive coarse geolocation (country, region, city, timezone) and then discards it. The raw IP is never written to the database. - **Trimmed URLs** — query strings and hash fragments are stripped from the page URL and referrer before sending. (UTM parameters are parsed out separately for attribution.) - **Bounded payloads** — custom properties are flattened and capped (30 keys, 512 chars per value) so you can't accidentally exfiltrate large or nested PII. #### Site key Your site key identifies your workspace to the ingestion endpoint. - **Format:** `ws_` followed by 32 hex characters (e.g. `ws_a1b2c3d4e5f6789abcdef0123456789a`). - **Public:** it lives in your page's HTML, so treat it as public — it grants the ability to *send* events to your workspace, nothing more. - **Separate from your App Token.** The mobile SDK [`appToken`](https://docs.linkforty.com/sdks/react-native/#api-reference) (`at_…`) and the web site key (`ws_…`) are independent. Rotating one never affects the other — important because an app token is baked into shipped app binaries (effectively non-rotatable), while a web key sits in scrapeable HTML and must be cheaply rotatable. ##### Rotating the site key If your key is being abused (spam events from a scraped snippet), rotate it from the dashboard under **Workspace Settings → Web Pixel → Rotate site key**. Rotation is an **owner-only** action performed from your logged-in session. The old key stops working immediately, so update your ` ``` #### Troubleshooting **No events showing up** 1. Confirm `data-workspace` is your real site key (not the `ws_…` placeholder). 2. Check the browser console/network tab for a `POST` to `…/api/pixel/v1/event` returning `204`. 3. A `204` with no row stored usually means an **origin allowlist** is configured under Workspace Settings and your domain isn't on it. 4. Verify Do Not Track / GPC isn't enabled in your test browser — the pixel intentionally goes silent when it is. **Conversions not attributed to a link** - Attribution is by UTM. Make sure the link a visitor arrives from carries `utm_source`/`utm_medium`/`utm_campaign` that match a link in your workspace. See [UTM Parameters](https://docs.linkforty.com/guides/links/utm-parameters/). **SPA route changes not tracked** - Ensure `data-spa` isn't set to `"false"`. The pixel relies on the History API; routers that do full page reloads will fire normal `page_view`s instead. #### Next steps - [Web Analytics dashboard](https://docs.linkforty.com/guides/analytics/web-analytics/) — read the data the pixel produces - [UTM Parameters](https://docs.linkforty.com/guides/links/utm-parameters/) — how link attribution works - [Mobile SDKs](https://docs.linkforty.com/sdks/react-native/) — the app-side counterpart to the pixel --- # Build an SDK ## SDK Specification Source: https://docs.linkforty.com/sdks/sdk-specification/ *Last updated: May 1, 2026* This document defines what every LinkForty SDK must implement. It is the canonical reference for contributors building SDKs in new languages or platforms. The full specification is maintained in the [Core repository](https://github.com/linkforty/core/blob/main/SDK_SPEC.md). This page provides the same content in a browsable format. **Info: Want to build a LinkForty SDK?** We welcome community SDKs for any platform. If you're building one, open an issue on [GitHub](https://github.com/linkforty/core/issues) to coordinate with the team and avoid duplicate work. #### Overview A LinkForty SDK must support 13 feature areas: | # | Feature | Description | |---|---------|-------------| | 1 | [Initialization](#1-initialization) | SDK setup, first launch detection | | 2 | [Deferred Deep Linking](#2-deferred-deep-linking) | Match installs to prior link clicks | | 3 | [Direct Deep Linking](#3-direct-deep-linking) | Handle links when app is already installed | | 4 | [Server-Side URL Resolution](#4-server-side-url-resolution) | Resolve Universal/App Links server-side | | 5 | [Event Tracking](#5-event-tracking) | In-app conversion events | | 6 | [Revenue Tracking](#6-revenue-tracking) | Structured purchase tracking | | 7 | [Link Creation](#7-programmatic-link-creation) | Create short links from the app | | 8 | [User Identity](#8-user-identity) | Associate a user ID with SDK operations | | 9 | [Attribution Data Access](#9-attribution-data-access) | Read cached attribution data locally | | 10 | [Data Management](#10-data-management) | Clear data and reset SDK state | | 11 | [Configuration](#11-configuration) | Required and optional settings | | 12 | [Error Handling](#12-error-handling) | Expected failure behaviors | | 13 | [Offline Resilience](#13-offline-resilience) | Event queuing and retry | --- #### 1. Initialization The SDK must be initialized before any other functionality is used. Initialization performs these steps: 1. **Validate configuration** (base URL, attribution window bounds, HTTPS enforcement) 2. **Set up internal components** (networking, storage, fingerprint collection, deep link handling) 3. **Detect first launch** and persist that state 4. **On first launch:** collect a device fingerprint and report the install to the server 5. **On subsequent launches:** load cached attribution data from local storage ##### Behavioral requirements - Initialization must be idempotent or guard against double-init - Methods that require initialization must fail clearly if called before init - The SDK should be usable as a singleton or single shared instance --- #### 2. Deferred Deep Linking Matches a new app install back to the link click that drove it, using probabilistic device fingerprinting. ##### What the SDK must do 1. On first launch, collect a device fingerprint containing: - User agent string - Timezone identifier - Device language - Screen dimensions (native pixels) - Platform (`iOS` / `Android`) - Platform version - App version - Optional device ID (IDFA/GAID, only if user consented) 2. Send the fingerprint to the server 3. Receive an [InstallResponse](#installresponse): install ID, whether attributed, confidence score, matched factors, and [DeepLinkData](#deeplinkdata) if attributed 4. Persist the install ID and deep link data locally 5. Deliver the result to a registered callback (or null/nil for organic installs) 6. Allow the callback to be registered before or after initialization (if registered after and data is already available, invoke immediately) **API endpoint:** `POST /api/sdk/v1/install` ##### App token (Cloud only) The SDK should accept an optional **`appToken`** during initialization and include it in the `/api/sdk/v1/install` request body. The app token is a public, workspace-scoped identifier that lets LinkForty Cloud attribute installs to the right workspace — including organic (unattributed) installs that wouldn't otherwise be tied to any workspace. - **Where to find it:** Dashboard → Workspace Settings → **App Token** - **Format:** `at_<32 hex chars>` - **Safety:** The token is *public* — designed to ship inside your app bundle. It only identifies which workspace owns the install; it cannot authenticate API actions or expose private data. - **Self-hosted (Core):** The endpoint accepts the field but ignores it. Self-hosted single-tenant deployments don't need it. - **Without a token:** Attributed installs (deeplink click → app open) still work via the existing fingerprint matcher. Organic installs (App Store discovery, social mentions, etc.) won't be visible in your workspace's analytics until the token is configured. Example request body: ```json { "userAgent": "...", "platform": "ios", "platformVersion": "17.4", "deviceId": "...", "appToken": "at_a1b2c3d4e5f6....", "sdkName": "ios", "sdkVersion": "1.4.0" } ``` Every install and event request should also include **`sdkName`** (one of `ios`, `android`, `flutter`, `react-native`, `expo`) and **`sdkVersion`** (the SDK's own semantic version). These let LinkForty report which SDKs and versions are in use and flag outdated integrations. Both are optional and backward-compatible — older SDKs that omit them simply store `null`. --- #### 3. Direct Deep Linking Handles the case where a user taps a LinkForty link and the app is already installed. The OS opens the app directly (via Universal Links on iOS, App Links on Android, or custom URL schemes). ##### What the SDK must do 1. Accept an incoming URL from the app's deep link handler 2. Parse the URL to extract the short code, UTM parameters, and custom query parameters 3. If the SDK is configured with a server connection, resolve the URL server-side for enriched data 4. Deliver the URL and parsed data to a registered callback 5. Support multiple callback registrations ##### Platform notes - **iOS:** The app must manually pass the URL to the SDK (from `onOpenURL` or `AppDelegate`) - **Android:** Same -- the app passes the intent URL to the SDK - **React Native / Expo:** The SDK may automatically listen via the platform's linking API, but must still support explicit URL handling --- #### 4. Server-Side URL Resolution When a Universal Link or App Link bypasses the redirect server (the OS opens the app directly), the SDK must resolve the link server-side to get the full link metadata. ##### What the SDK must do 1. Extract path segments from the URL to determine the resolve path: - Single segment: `/api/sdk/v1/resolve/{shortCode}` - Two segments: `/api/sdk/v1/resolve/{templateSlug}/{shortCode}` 2. Collect a device fingerprint and send it as query parameters (`fp_tz`, `fp_lang`, `fp_sw`, `fp_sh`, `fp_platform`, `fp_pv`) 3. Return the enriched deep link data from the server response — the same [DeepLinkData](#deeplinkdata) shape the install endpoint returns 4. **Fall back to local URL parsing** if the server request fails (network error, timeout, etc.) --- #### 5. Event Tracking Tracks in-app events for conversion attribution. Events are tied to the install ID so the server can correlate them with the original link click. ##### What the SDK must do 1. Accept an event name and optional properties dictionary 2. Send the event to the server with the install ID and timestamp 3. **Stamp the last-click attribution context** on every event (see below) 4. Include `sdkName` and `sdkVersion` (as on the install request) 5. Handle failures gracefully (see [Offline Resilience](#13-offline-resilience)) **API endpoint:** `POST /api/sdk/v1/event` ##### Last-click attribution Every event should be credited to the deep link that **most recently opened the app** — a deferred install open *or* a direct re-engagement open — so the server can attribute in-app activity to the link that drove it. The SDK keeps an "active attribution context" that the newest deep-link open supersedes, persists it across app restarts, and stamps these fields onto every event: | Field | Description | |-------|-------------| | `attributedLinkId` | The link currently credited (last-click). Omitted for organic activity (no deep link has opened the app). | | `attributedClickId` | The originating click id, when known. | | `linkOpenedAt` | ISO 8601 timestamp of when that deep link opened the app. | | `sessionId` | A per-app-open session id (generated on cold start, rotated on each new deep-link open) used to group a visit's screens. | The conversion window (how long a stamped event still counts) and session grouping are applied **server-side at query time** — the SDK only reports the active link, when it opened, and the current session. Example request body: ```json { "installId": "...", "eventName": "add_to_cart", "eventData": { "productId": "123" }, "timestamp": "2026-06-11T12:00:00.000Z", "sdkName": "ios", "sdkVersion": "1.4.0", "attributedLinkId": "...", "attributedClickId": "...", "linkOpenedAt": "2026-06-11T11:58:00.000Z", "sessionId": "..." } ``` ##### Screen views Screen views are reported as a regular event named **`screen_view`**, with the screen name in `eventData.screen` (and the previously tracked screen in `eventData.previousScreen` when known). They flow through the same pipeline and carry the same attribution stamp, which powers the per-link screen-flow funnel. --- #### 6. Revenue Tracking A convenience for tracking revenue-specific events with structured amount and currency fields. The Events dashboard aggregates revenue using this convention. ##### What the SDK must do 1. Provide a `trackRevenue(amount, currency, properties?)` method 2. Validate that amount is non-negative 3. Send as an event via `trackEvent` with these exact fields: - **Event name:** `revenue` - **Event data:** `{ revenue: , currency: , ...properties }` ##### Convention (all SDKs must follow this exactly) | Field | Value | Example | |-------|-------|---------| | Event name | `"revenue"` | — | | `eventData.revenue` | Numeric amount (double/float) | `29.99` | | `eventData.currency` | ISO 4217 currency code | `"USD"` | The [Events dashboard](https://docs.linkforty.com/guides/analytics/events/) computes total revenue as `SUM(event_data->>'revenue') WHERE event_name = 'revenue'`. If an SDK uses a different event name or field name, revenue will not be aggregated correctly. --- #### 7. Programmatic Link Creation Allows apps to create short links on behalf of the user (e.g., for sharing content). ##### What the SDK must do 1. Accept link creation options including an optional template ID, deep link parameters, title, description, custom code, UTM parameters, external user ID, and optional Open Graph overrides (`ogTitle`, `ogDescription`, `ogImageUrl`, `ogType`) so a shared item unfurls with its own preview 2. Require an API key to be configured 3. Send to `POST /api/sdk/v1/links` with an optional `templateId` in the request body: - **With `templateId`:** Uses the specified template (must belong to the organization; falls back to default if not found) - **Without `templateId`:** Uses the organization's default template (`is_default = true`), or the most recently created template if no default is set 4. Return the created link's URL, short code, link ID, and deduplication status 5. If an SDK-level external user ID is set (see [User Identity](#8-user-identity)), include it in the request body unless overridden per-call **Warning:** This feature requires an API key. SDKs must fail clearly if no API key is configured. --- #### 8. User Identity Allows apps to associate an external user identifier with SDK operations, primarily link creation. This enables per-user deduplication and share attribution on the dashboard. ##### What the SDK must do 1. Provide a method to set an external user ID (any string: UUID, email, integer, etc.) 2. Provide a method to get the current external user ID 3. Passing null/nil clears the stored ID 4. The stored ID is automatically attached to all `createLink()` calls unless overridden per-call via `CreateLinkOptions.externalUserId` 5. The ID is stored in memory only (not persisted to disk) 6. Clearing SDK data or resetting the SDK also clears the external user ID ##### Behavioral requirements - The external user ID does not require initialization — it can be set before or after `initialize()` - Per-call `externalUserId` in `CreateLinkOptions` takes precedence over the SDK-level value - The value is not sent to any endpoint automatically — it is only included in link creation requests --- #### 9. Attribution Data Access The SDK must provide access to cached attribution data from local storage without requiring a network call. ##### What the SDK must expose - **Install ID** -- the server-assigned UUID for this install - **Install data** -- the deep link data from attribution (null if organic) - **First launch status** -- whether this is the first launch of the app --- #### 10. Data Management ##### What the SDK must support - **Clear data** -- wipe all locally stored SDK data (install ID, attribution data, cached deep links, event queue). Used for testing and GDPR compliance. - **Reset** -- return the SDK to an uninitialized state so it can be re-initialized. This is separate from clearing data. --- #### 11. Configuration ##### Required configuration | Field | Description | |-------|-------------| | Base URL | The LinkForty server URL (e.g., `https://go.yourdomain.com`) | ##### Optional configuration | Field | Description | Default | |-------|-------------|---------| | API key | Required for link creation and Cloud features | None | | App token | Public workspace identifier for Cloud install scoping (format: `at_<32 hex>`). See [Section 2 → App token](#app-token-cloud-only). Self-hosted Core ignores this field. | None | | Debug mode | Enable verbose logging | Off | | Attribution window | How far back to match installs to clicks | 7 days (168 hours) | ##### Validation requirements - Base URL must be HTTPS (except `localhost` / `127.0.0.1` for local development) - Attribution window must be between 1 hour and 2160 hours (90 days) --- #### 12. Error Handling SDKs must handle these error scenarios. The mechanism (typed enums, error codes, exceptions) is platform-specific. | Scenario | Expected behavior | |----------|-------------------| | Method called before initialization | Throw/return an error | | Double initialization | Warn or throw | | Network failure during install report | Treat as organic install, deliver null to callback | | Network failure during URL resolution | Fall back to local URL parsing | | Network failure during event tracking | Queue the event for retry | | Link creation without API key | Throw/return an error | | Server returns error response | Surface the error to the caller | | Invalid configuration | Throw/return an error during initialization | --- #### 13. Offline Resilience ##### Event queue Events should be queued locally when the network is unavailable and retried when connectivity is restored. - Maximum queue size: 100 events - Queue must persist across app restarts (local storage) - Provide a way to manually flush the queue - Provide a way to clear the queue without sending - Provide a way to check the queue size ##### Other operations - **Install reporting:** if the network call fails on first launch, treat as organic. The install can be re-attributed on a subsequent launch if the SDK detects it hasn't successfully reported yet. - **URL resolution:** fall back to local parsing (never block the deep link flow on a network call) --- #### API Endpoints Reference All endpoints are relative to the configured base URL. ##### Core endpoints (available in both self-hosted Core and Cloud) | Method | Path | Auth | Purpose | |--------|------|------|---------| | `POST` | `/api/sdk/v1/install` | None | Report install, get deferred deep link. Body accepts optional `appToken` for Cloud workspace scoping. | | `GET` | `/api/sdk/v1/resolve/:shortCode` | None | Resolve link without redirect | | `GET` | `/api/sdk/v1/resolve/:templateSlug/:shortCode` | None | Resolve template link without redirect | | `POST` | `/api/sdk/v1/event` | None | Track in-app events | | `GET` | `/api/sdk/v1/health` | None | Health check | ##### Cloud-only endpoints | Method | Path | Auth | Purpose | |--------|------|------|---------| | `POST` | `/api/sdk/v1/links` | API key | Create link (optional `templateId`; uses default template if omitted) | ##### Authentication When an API key is configured, send it as: `Authorization: Bearer ` --- #### Data Models Canonical field names for cross-SDK data models. SDKs should use platform-appropriate naming conventions (camelCase for Swift/Kotlin/JS/TS) but the data must map to these fields. ##### DeepLinkData **One shape, both paths.** `/api/sdk/v1/resolve` (app already installed) and `/api/sdk/v1/install` (deferred) return the same field names for the same things. An SDK should decode both into one model. Before `@linkforty/core` 1.22 each endpoint built its own object and the two had drifted apart; they are now projected through a single function server-side. | Field | Type | Always present | Description | |-------|------|----------------|-------------| | shortCode | string | Yes | The link's short code | | linkId | string | No | Link UUID. See below — this is what credits a new user's first session | | deepLinkPath | string | No | In-app routing path (e.g. `/product/123`) | | appScheme | string | No | App URI scheme (e.g. `myapp`) | | iosUrl | string | No | iOS destination URL | | androidUrl | string | No | Android destination URL | | webUrl | string | No | Web fallback URL | | utmParameters | map | No | UTM tracking parameters | | customParameters | map | No | Custom deep link parameters | | clickedAt | datetime | Yes | When the link was clicked (ISO 8601) | | isDeferred | boolean | Yes | `true` for a post-install match, `false` for a direct open | **Caution: Empty fields are omitted, not null** A field with no value is **absent from the JSON entirely** rather than present as `null`. Decoders must treat a missing key and a null key as the same thing, and must not require any field marked "No" above. ###### Deferred-only fields These two appear only when `isDeferred` is `true`, because they describe the quality of a probabilistic match: | Field | Type | Description | |-------|------|-------------| | confidenceScore | number | How confident the fingerprint match was (0-100) | | matchedFactors | string[] | Which fingerprint factors matched | They are exposed deliberately. A deferred match is a probability, not a fact, and an app that knows the score can decide how far to act on it — routing straight to a paywalled item on a weak match is a worse experience than landing on the category page. Most providers do not tell you this. ###### `linkId` and first-session credit `linkId` identifies the link that acquired the user. An SDK should persist it from the install response and attach it to the first session's events, so that session is credited to the link rather than appearing organic. ###### Legacy fields The install response **also** carries four fields under their pre-1.22 names: | Legacy field | Same value as | |---|---| | originalUrl | *(no canonical equivalent — the link's original destination)* | | webFallbackUrl | `webUrl` | | targetingRules | *(no canonical equivalent)* | | deepLinkParameters | `customParameters` | These are **supported, not deprecated**. Apps in the field read them, app updates take weeks to roll out, and at least one shipping SDK reads `deepLinkParameters` in preference to `customParameters`. They are emitted verbatim, nulls included, and are not going away without notice. If you are writing a new SDK, read the canonical names. If you are maintaining an existing one, there is no urgency to switch, and no reason to stop reading the legacy names until you know your users have updated. ##### InstallResponse | Field | Type | Required | Description | |-------|------|----------|-------------| | installId | string | Yes | Server-assigned install UUID | | attributed | boolean | Yes | Whether install was matched to a click | | confidenceScore | number | Yes | Match confidence (0-100) | | matchedFactors | string[] | Yes | Which fingerprint factors matched | | deepLinkData | DeepLinkData | No | Link data if attributed (null if organic) | `confidenceScore` and `matchedFactors` appear both at the top level here and inside `deepLinkData`. They carry the same values; read whichever is convenient. **Note: Organic installs return `null`, never `{}`** `deepLinkData` is `null` for an unattributed install. An empty object is not a valid `DeepLinkData` — it has no `shortCode` — and a strict decoder that receives one will fail and can take SDK initialization down with it. ##### CreateLinkOptions | Field | Type | Required | Description | |-------|------|----------|-------------| | templateId | string | No | Template UUID (uses organization's default template if omitted or not found) | | templateSlug | string | No | Template slug (for URL construction) | | deepLinkParameters | map | No | In-app routing parameters | | title | string | No | Link title | | description | string | No | Link description | | customCode | string | No | Custom short code | | utmParameters | UTMParameters | No | Campaign tracking parameters | | externalUserId | string | No | Identifier for the app user creating the link (enables per-user deduplication and share attribution) | ##### CreateLinkResult | Field | Type | Required | Description | |-------|------|----------|-------------| | url | string | Yes | Full shareable URL | | shortCode | string | Yes | Generated short code | | linkId | string | Yes | Link UUID | | deduplicated | boolean | No | True if an existing link was returned instead of creating a new one | ##### UTMParameters | Field | Type | Required | |-------|------|----------| | source | string | No | | medium | string | No | | campaign | string | No | | term | string | No | | content | string | No | ##### DeviceFingerprint | Field | Type | Required | Description | |-------|------|----------|-------------| | userAgent | string | Yes | App/version + OS/version | | timezone | string | No | IANA timezone identifier | | language | string | No | Device language/locale | | screenWidth | number | No | Native screen width in pixels | | screenHeight | number | No | Native screen height in pixels | | platform | string | No | `iOS`, `Android`, etc. | | platformVersion | string | No | OS version string | | appVersion | string | No | Host app version | | deviceId | string | No | IDFA/GAID (only if user consented) | | attributionWindowHours | number | No | Attribution window in hours | --- #### Feature Parity Matrix Current status across existing SDKs. Update this table when adding features or SDKs. | # | Feature | iOS | React Native | Android | Expo | |---|---------|-----|-------------|---------|------| | 1 | Initialization | Done | Done | Done | Done | | 2 | Deferred deep linking | Done | Done | Done | Done | | 3 | Direct deep linking | Done | Done | Done | Done | | 4 | Server-side URL resolution | Done | Done | Done | Done | | 5 | Event tracking | Done | Done | Done | Done | | 6 | Revenue tracking | Done | Done | Done | Done | | 7 | Link creation | Done | Done | Done | Done | | 8 | User identity | Done | Done | Done | Done | | 9 | Attribution data access | Done | Partial | Done | Done | | 10 | Data management | Done | Partial | Done | Done | | 11 | Configuration validation | Done | Missing | Done | Done | | 12 | Error handling | Done | Partial | Done | Done | | 13 | Offline resilience | Done | Missing | Done | Done | ##### React Native parity notes - **Attribution data access:** `isFirstLaunch()` is private, not exposed to consumers - **Data management:** No `reset()` method (only `clearData()`) - **Error handling:** Uses generic `Error` throws instead of typed error cases - **Configuration validation:** No HTTPS enforcement or attribution window bounds checking - **Offline resilience:** Events are fire-and-forget; no queue, no retry on failure --- #### Getting Started as a Contributor ##### Building your SDK 1. Read the [React Native SDK source](https://github.com/linkforty/core) as a reference implementation 2. Implement the 13 feature areas in order (initialization first) 3. Use the [API endpoints](#api-endpoints-reference) and [data models](#data-models) as your contract 4. Test against a local LinkForty Core instance ([quick start](https://docs.linkforty.com/getting-started/quick-start/)) ##### Submission requirements Before an SDK can be listed on the LinkForty docs, we verify it works end-to-end. Please provide: 1. **Repository link** and intended package registry name (npm, pub.dev, Maven, CocoaPods, etc.) 2. **Example app** or `/example` folder demonstrating the core flows: initialization, deferred deep linking, direct deep linking, and link creation 3. **CI pipeline** with linting and tests (GitHub Actions or equivalent) 4. **Install and quickstart documentation** in the README ##### How SDKs are listed | Tier | Requirements | Listed as | |------|-------------|-----------| | **Community** | Passes end-to-end verification, meets submission requirements | "Community SDK" on the docs site with link to your repo | | **Official** | Multiple stable releases, co-maintained with LinkForty team, transferred to the LinkForty GitHub org | "Official SDK" with full documentation on the docs site | To get started, open an issue on [GitHub](https://github.com/linkforty/core/issues) to coordinate with the team and avoid duplicate work. --- # Fundamentals ## What is Deep Linking? Complete Guide to Mobile Deep Links Source: https://docs.linkforty.com/learn/what-is-deep-linking/ Deep linking is a technique that allows you to create links that open specific content or pages within a mobile app, rather than just opening the app's homepage. Think of it as the mobile equivalent of hyperlinking to a specific page on a website. #### The Problem Deep Linking Solves Without deep linking, when users click a link to your app: - The app opens to the home screen (poor UX) - Users must manually navigate to find content - Context from the original link is lost - Conversion rates suffer **With deep linking:** - Users land directly on the intended content - Seamless transition from link to app - Context is preserved (campaign data, product ID, etc.) - Higher conversion rates and better user experience #### How Deep Linking Works ##### Basic Flow ``` User clicks link → Link contains destination data → App opens → App reads data → Routes user to content ``` ##### Example **Standard web link:** ``` https://example.com/products/shoe-123 → Opens in mobile browser → User must find and open app manually → Context lost ``` **Deep link:** ``` myapp://products/shoe-123 → Opens app directly → Navigates to product page automatically → User sees shoe-123 immediately ``` --- #### Types of Deep Links ##### 1. Standard Deep Links (URL Schemes) The simplest form using custom URL schemes. **Format:** `myapp://path/to/content` **How it works:** 1. App registers custom scheme (e.g., `myapp://`) 2. Operating system associates scheme with your app 3. Links with that scheme open your app **Example:** ``` myapp://products/shoe-123 myapp://profile/user456 myapp://settings ``` **Limitations:** - Only works if app is installed - If app not installed → error or nothing happens - Not great for sharing publicly - Can have scheme conflicts (multiple apps using same scheme) **Best for:** Internal app-to-app communication --- ##### 2. Universal Links (iOS) & App Links (Android) Modern, platform-native deep linking using regular HTTPS URLs. **Format:** `https://yourdomain.com/path/to/content` **How it works:** 1. You own a domain (e.g., `go.yourapp.com`) 2. You host a verification file on that domain 3. Operating system verifies you own both the domain and app 4. Links from that domain open your app (if installed) or website (if not) **Example:** ``` https://go.yourapp.com/products/shoe-123 If app installed → Opens app to product page If app NOT installed → Opens website product page ``` **Advantages:** - Graceful fallback to website - No broken links - Works as regular web links - No scheme conflicts - Better for sharing (looks like normal URL) **Platform specifics:** - **iOS:** Universal Links (requires `apple-app-site-association` file) - **Android:** App Links (requires `assetlinks.json` file) **Best for:** Public sharing, marketing campaigns, social media --- ##### 3. Deferred Deep Links The most powerful type: routes users to content **even after installing the app**. **The "Magic" Flow:** 1. **User clicks link** (doesn't have app installed) ``` https://go.yourapp.com/products/shoe-123 ``` 2. **Redirected to App Store/Google Play** - System detects app not installed - Link data is "remembered" (via fingerprinting or device ID) 3. **User installs app** 4. **User opens app for first time** 5. **App retrieves original link data** - Knows user came from the shoe-123 link 6. **App routes user to shoe-123 product page** - User sees the exact product they clicked - Seamless experience **How it "remembers" the link:** - **Fingerprinting:** Matches device characteristics (IP, user agent, screen size, timezone, etc.) - **Accuracy:** 70-80% with good fingerprinting (like LinkForty uses) - **Privacy-friendly:** No persistent device IDs required **Use cases:** - Marketing campaigns targeting new users - Referral programs ("Install and get reward") - Product-specific ads for non-users - Email campaigns to acquire new users **Best for:** User acquisition campaigns --- ##### 4. Contextual Deep Links Deep links that carry additional context or data beyond just routing. **Example:** ``` https://go.yourapp.com/products/shoe-123? utm_source=instagram& utm_campaign=summer-sale& referrer=user789& discount=SUMMER20 ``` **The app receives:** - Destination: Product shoe-123 - Traffic source: Instagram - Campaign: summer-sale - Referrer: user789 - Discount code: SUMMER20 **The app can then:** - Apply the discount automatically - Credit user789 with referral - Track campaign performance - Personalize the experience **Best for:** Advanced attribution and personalization --- #### Platform Differences ##### iOS (Universal Links) **Requirements:** 1. Owned domain (e.g., `go.yourapp.com`) 2. HTTPS hosting for verification file 3. `apple-app-site-association` file hosted at `/.well-known/` 4. Associated Domains capability in Xcode 5. AppDelegate implementation **Verification file example:** ```json { "applinks": { "apps": [], "details": [ { "appID": "TEAMID.com.yourapp.bundle", "paths": ["*"] } ] } } ``` **Advantages:** - Native iOS feature (secure) - No custom URL scheme needed - Graceful web fallback **Limitations:** - Requires domain ownership - Setup complexity - Cached by iOS (changes take time to propagate) --- ##### Android (App Links) **Requirements:** 1. Owned domain 2. HTTPS hosting 3. `assetlinks.json` file hosted at `/.well-known/` 4. Intent filters in AndroidManifest 5. SHA-256 fingerprint of signing key **Verification file example:** ```json [{ "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.yourapp", "sha256_cert_fingerprints": ["FA:C6:17:..."] } }] ``` **Advantages:** - Native Android feature - Automatic verification - No disambiguation prompts **Limitations:** - Requires signing key fingerprint - Domain verification needed - More complex than custom schemes --- #### Common Use Cases ##### 1. E-commerce **Product deep links:** ``` https://go.mystore.com/products/winter-jacket-2024 ``` - Click from Instagram ad → App opens to product - User can buy immediately - Increases conversion by 3-5x --- ##### 2. Content & Media **Article deep links:** ``` https://news.myapp.com/articles/breaking-news-123 ``` - Share news article on Twitter - Readers with app installed → Opens in app - Readers without app → Opens mobile web --- ##### 3. Referral Programs **Referral deep links:** ``` https://go.myapp.com/invite?code=FRIEND20&referrer=user456 ``` - User shares invite link - Friend clicks, installs app - Friend gets $20 credit - User456 gets referral credit --- ##### 4. Email & Push Notifications **Contextual deep links:** ``` https://go.myapp.com/orders/12345?source=email ``` - Email: "Your order has shipped" - User clicks tracking link - App opens to order details - Track that email drove engagement --- ##### 5. QR Codes **Offline-to-online:** ``` https://go.myapp.com/menu/table-7?venue=downtown ``` - QR code on restaurant table - Scan to view menu in app - Table number pre-filled for ordering --- #### Benefits of Deep Linking ##### For Users - **Seamless experience** - Direct access to content - **Faster navigation** - No manual searching - **Context preserved** - Discounts, referrals, etc. work automatically ##### For Businesses - **Higher conversion rates** - 2-5x improvement typical - **Better attribution** - Know what drove installs/engagement - **Lower acquisition costs** - More efficient campaigns - **Personalization** - Deliver targeted experiences ##### For Developers - **Unified links** - Same URL for app and web - **Cross-platform** - Works on iOS and Android - **Shareability** - Normal HTTPS links work everywhere --- #### Deep Linking Best Practices ##### 1. Always Provide Fallbacks ``` If app installed → Open app If app NOT installed → Open website or App Store Never → Show error or dead link ``` ##### 2. Keep Links Simple ``` Good: https://go.app.com/products/123 Bad: https://api.internal.prod.app.com/v2/entities/product?id=123&session=xyz ``` ##### 3. Use HTTPS Universal/App Links - More reliable than custom schemes - Better user experience - Works as regular web links ##### 4. Test Thoroughly - Test with app installed - Test without app installed - Test on both iOS and Android - Test fallback scenarios ##### 5. Track Everything - Click events - Install attribution - In-app navigation - Conversion rates --- #### How to Implement Deep Linking ##### DIY Approach **What you need:** 1. Set up domain and HTTPS hosting 2. Create and host verification files (AASA for iOS, assetlinks for Android) 3. Configure Xcode (iOS) or AndroidManifest (Android) 4. Implement SDK code to handle links 5. Build fingerprinting for deferred deep linking 6. Create link management system 7. Set up analytics tracking **Time investment:** 2-4 weeks of development **Challenges:** - Complex setup (especially verification files) - Platform-specific code - Fingerprinting accuracy - Link management at scale --- ##### Using a Platform **What platforms provide:** - Automatic verification file generation - Simple SDK integration - Fingerprinting for deferred deep links - Link creation and management UI - Analytics and attribution tracking - Universal fallback handling [**LinkForty**](https://linkforty.com) is an open-source mobile attribution and deep link platform — self-hosted or managed Cloud — with full deferred deep linking, privacy-friendly fingerprinting (no persistent IDs), and complete link management and analytics. **Time investment:** 1-2 days of integration --- #### Why LinkForty for Deep Linking? LinkForty provides everything you need for deep linking: ##### Features - **Deferred deep linking** - Routes users after install (70%+ accuracy) - **Universal Links & App Links** - Auto-generates verification files - **Link management** - Create, edit, track thousands of links - **Analytics** - Click tracking, install attribution, conversion metrics - **QR codes** - Generate codes for offline campaigns - **UTM tracking** - Full campaign attribution ##### Why Choose LinkForty? **Open Source:** - Full source code access (AGPL-3.0 license) - Community-driven development - No vendor lock-in - Self-host for free **Privacy-First:** - No persistent device IDs required - GDPR compliant fingerprinting - Data ownership (self-hosted option) - No third-party data sharing **Developer-Friendly:** - Simple SDK integration (5 minutes) - React Native, iOS, Android support - Clear documentation - API access for automation **Cost-Effective:** - Free forever (self-hosted, open source) - Affordable managed cloud — see the [pricing page](https://linkforty.com/pricing) - Unlimited links - No usage-based pricing surprises ##### Get Started with LinkForty **Cloud (Managed):** 1. Sign up at [linkforty.com](https://linkforty.com) 2. Create your first link 3. Integrate SDK (5 minute setup) 4. Start tracking **Self-Hosted (Free):** 1. Install `@linkforty/core` from npm 2. Deploy to your infrastructure 3. Integrate SDK 4. Full control [View Quick Start Guide →](https://docs.linkforty.com/getting-started/quick-start/) [View SDK Integration Guide →](https://docs.linkforty.com/guides/attribution/sdk-integration/) --- #### Next Steps Now that you understand deep linking, explore these related topics: - **[What is Deferred Deep Linking?](https://docs.linkforty.com/learn/deferred-deep-linking-explained/)** - Deep dive into install attribution - **[Universal Links vs App Links](https://docs.linkforty.com/learn/universal-links-vs-app-links/)** - Platform comparison and implementation --- #### Frequently Asked Questions ##### What's the difference between deep linking and deferred deep linking? **Deep linking** routes users to content in an installed app. **Deferred deep linking** routes users to content *after* they install the app (remembers the link through the install process). ##### Do I need a domain for deep linking? For **Universal Links (iOS)** and **App Links (Android)**: Yes, you need a domain. For **custom URL schemes**: No, but they have significant limitations. **Best practice:** Use a domain for production deep linking. ##### Can deep links work without the app installed? **Standard deep links (custom schemes):** No - will fail if app not installed. **Universal/App Links:** Yes - fall back to website if app not installed. **Best approach:** Always use Universal Links or App Links for public sharing. ##### How accurate is deferred deep linking? **Accuracy varies by method:** - **Fingerprinting:** 70-80% (LinkForty uses this) - **Device ID matching:** 90-95% (requires user consent post-iOS 14.5) - **Universal Links (iOS only):** 100% (requires app installed before click) LinkForty achieves 70%+ accuracy using privacy-friendly fingerprinting. ##### Is deep linking GDPR compliant? Yes, when implemented properly: - Fingerprinting doesn't use persistent IDs - No personal data collected - IP addresses processed for routing only LinkForty's fingerprinting is GDPR compliant. ##### What happens if two apps use the same URL scheme? This is called a **scheme conflict**. The operating system may: - Prompt user to choose which app to open - Open the most recently installed app - Open a default app **Solution:** Use Universal Links / App Links (domain-based) instead of custom URL schemes. --- #### Summary Deep linking transforms the mobile app experience by enabling direct navigation to specific content. Whether you're building an e-commerce app, content platform, or any mobile product, deep linking is essential for: - **User experience** - Seamless navigation - **Marketing** - Higher conversion rates - **Attribution** - Track campaign performance - **Growth** - Efficient user acquisition **Ready to implement deep linking?** LinkForty provides everything you need: - Deferred deep linking for install attribution - Universal Links / App Links support - Link management and analytics - Open source & privacy-first - Free to self-host, or a managed cloud option [Get Started with LinkForty →](https://docs.linkforty.com/getting-started/quick-start/) --- **Questions?** - [View all guides](https://docs.linkforty.com/guides/links/creating-links/) - [GitHub Discussions](https://github.com/linkforty/core/discussions) - [Contact Support](mailto:inbox@linkforty.com) --- ## What is Mobile Attribution? Complete Guide to App Install Tracking Source: https://docs.linkforty.com/learn/what-is-mobile-attribution/ Mobile attribution is the process of determining which marketing campaign, channel, or touchpoint caused a user to install your mobile app or take an action within it. It answers the critical question: **"What drove this user to install my app?"** #### The Problem Mobile Attribution Solves Without attribution, you're flying blind: - You don't know which ads actually drive installs - You can't calculate ROI for marketing spend - You waste budget on ineffective campaigns - You can't optimize campaigns based on performance **With mobile attribution:** - Know exactly which campaigns drive installs - Calculate precise ROI for every dollar spent - Optimize budget allocation to best-performing channels - Track the complete user journey from ad to install to conversion **Example:** ``` You spend $10,000 on ads across: - Instagram: $4,000 - Facebook: $3,000 - TikTok: $2,000 - Google: $1,000 Without attribution: You have 1,000 installs. No idea which channel worked. With attribution: - Instagram: 600 installs ($6.67 per install) ← Best performer - Facebook: 250 installs ($12 per install) - TikTok: 100 installs ($20 per install) ← Worst performer - Google: 50 installs ($20 per install) Action: Shift TikTok budget to Instagram. Save $4,000, get 1,200 installs. ``` --- #### How Mobile Attribution Works ##### The Attribution Flow ``` 1. User sees ad/link → Clicks 2. Attribution platform records click (fingerprint created) 3. User downloads app from App Store/Google Play 4. User opens app for first time 5. SDK contacts attribution platform 6. Platform matches install to original click (via fingerprint) 7. Install attributed to campaign ``` ##### What Gets Tracked **Click Event:** - Timestamp of click - Device information (IP, user agent, screen size, language, timezone) - Traffic source (Instagram, Facebook, email, etc.) - Campaign details (UTM parameters) - Geographic location **Install Event:** - Timestamp of first app open - Device information (same data points) - App version - OS version **Attribution Match:** - Compare click data to install data - If fingerprints match → Install attributed to that click - If no match → Labeled as "organic" install --- #### Attribution Models Explained ##### 1. Last-Click Attribution **How it works:** Credit goes to the **last** marketing touchpoint before install. **Example:** ``` User journey: 1. Sees Facebook ad (doesn't click) 2. Sees Instagram ad → Clicks 3. Installs app Attribution: Instagram gets 100% credit ``` **Pros:** - Simple to understand and implement - Clear cause-and-effect **Cons:** - Ignores earlier touchpoints that may have influenced decision - Over-credits final touch **Best for:** Simple campaigns, direct response marketing --- ##### 2. Multi-Touch Attribution **How it works:** Credit is distributed across **multiple** touchpoints in the user journey. **Example:** ``` User journey: 1. Sees Facebook ad → Clicks (but doesn't install) 2. Sees email campaign → Clicks (but doesn't install) 3. Sees Instagram ad → Clicks → Installs Attribution: - Facebook: 30% credit - Email: 30% credit - Instagram: 40% credit (higher as final touch) ``` **Pros:** - More accurate picture of user journey - Credits all influencing touchpoints **Cons:** - More complex to implement - Requires tracking multiple clicks per user **Best for:** Complex marketing funnels, brand awareness campaigns --- ##### 3. Fingerprint Attribution **How it works:** Matches installs to clicks by creating a **device fingerprint** (combination of device characteristics). **Fingerprint components:** - IP address - User agent (device model, OS version, browser) - Screen resolution - Language settings - Timezone - ISP information **Example:** ``` Click fingerprint: { ip: "192.168.1.100", userAgent: "iPhone 15 Pro, iOS 17.2", screen: "1179x2556", language: "en-US", timezone: "America/Los_Angeles" } Install fingerprint (same user): { ip: "192.168.1.100", userAgent: "iPhone 15 Pro, iOS 17.2", screen: "1179x2556", language: "en-US", timezone: "America/Los_Angeles" } Match confidence: 95% → Install attributed to click ``` **Accuracy:** - 70-80% accuracy with good fingerprinting (like LinkForty) - No user consent required (no personal data) - Works post-iOS 14.5 (no IDFA needed) **Pros:** - Privacy-friendly (no persistent IDs) - Works without user permission - GDPR compliant **Cons:** - Not 100% accurate (can have false positives) - Less accurate on shared IPs (WiFi networks) **Best for:** Privacy-first platforms (like LinkForty), post-ATT era --- ##### 4. Device ID Attribution (Declining) **How it works:** Matches using persistent device identifiers (IDFA on iOS, GAID on Android). **What changed:** - **iOS 14.5+ (April 2021):** Apple requires user permission to access IDFA - **Result:** ~70% of iOS users decline permission (can't be tracked this way) - **Android:** Google plans similar restrictions **Accuracy:** - 95-100% when user grants permission - 0% when user declines permission (most users) **Current state:** **Declining effectiveness**, fingerprinting is the replacement --- #### Attribution Windows The **attribution window** is the time period during which a click can be credited for an install. **Example with 7-day window:** ``` Day 1: User clicks ad Day 2-7: User can install → Click gets credit Day 8+: User installs → Labeled as "organic" (window expired) ``` ##### Common Attribution Windows | Window | Use Case | |--------|----------| | **24 hours** | Direct response campaigns (strong intent) | | **7 days** | Industry standard (most platforms) | | **14 days** | Consideration products (requires research) | | **30 days** | High-ticket items (long decision process) | | **90 days** | Complex B2B products | **LinkForty default:** 7 days (configurable per link) --- #### Privacy Considerations in Mobile Attribution ##### The Privacy Landscape **Before iOS 14.5 (2021):** - Apps could access IDFA without permission - Attribution platforms tracked users across apps - High accuracy (95%+) but privacy concerns **After iOS 14.5 (April 2021):** - Apps must ask permission (ATT framework) - ~70% of users decline - Attribution platforms switched to fingerprinting **GDPR (Europe, 2018):** - Personal data requires consent - Fingerprinting allowed (not personal data) - Users can request data deletion --- ##### Privacy-Friendly Attribution **What LinkForty does:** - **Fingerprinting only** (no persistent IDs) - **No personal data collected** (no names, emails, phone numbers) - **GDPR compliant** (fingerprints aren't personal data) - **Data ownership** (self-hosted option available) - **User deletions supported** (via API) **What LinkForty doesn't do:** - No IDFA/GAID tracking - No cross-app tracking - No third-party data sharing - No ad network integrations (unless you want them) **Result:** 70-80% attribution accuracy with full privacy compliance --- #### What is an MMP (Mobile Measurement Partner)? An **MMP** is a third-party platform that provides mobile attribution and analytics services. ##### What MMPs Provide **Core Features:** - Attribution tracking (click to install matching) - Deep linking (route users to content) - Analytics dashboard (campaign performance) - Fraud detection (block fake installs) - Ad network integrations (AppsFlyer, Branch: 500+ integrations) **Advanced Features (Enterprise MMPs):** - SKAdNetwork integration (iOS privacy-safe attribution) - Audience segmentation - Predictive analytics - Custom reporting --- ##### Where LinkForty fits LinkForty is a **deep-linking-first** platform for **organic and owned channels** — your links, email, social, referrals, QR codes, and content. It starts from the link and follows the user after the click, through install, in-app events, and revenue, on web and mobile. It's open source, privacy-first (no IDFA/GAID, fingerprint-based), and self-hostable, so you own your data. The question it answers is simple: what did the people who clicked our links actually do next? --- #### Key Metrics in Mobile Attribution ##### 1. Install Attribution Rate **Formula:** `(Attributed Installs / Total Installs) × 100` **Example:** - Total installs: 1,000 - Attributed installs: 750 - Organic installs: 250 - Attribution rate: 75% **Good attribution rate:** 70-85% **If lower than 70%:** - Increase attribution window - Check SDK integration - Verify fingerprinting accuracy --- ##### 2. Cost Per Install (CPI) **Formula:** `Total Ad Spend / Number of Attributed Installs` **Example:** - Ad spend: $10,000 - Attributed installs: 1,000 - CPI: $10 **Industry benchmarks:** - Gaming: $1-$5 - E-commerce: $5-$15 - Finance/Banking: $20-$80 - Enterprise B2B: $100+ --- ##### 3. Install-to-Event Rate **Formula:** `(Users Who Completed Event / Total Installs) × 100` **Example:** - Installs: 1,000 - Users who made purchase: 100 - Install-to-purchase rate: 10% **Track events:** - Registration completed - First purchase - Tutorial completed - Subscription started --- ##### 4. Return on Ad Spend (ROAS) **Formula:** `Revenue Generated / Ad Spend` **Example:** - Ad spend: $10,000 - Revenue from those users: $30,000 - ROAS: 3x (or 300%) **Good ROAS:** - E-commerce: 3-5x - Subscription: 2-4x - Gaming: 1.5-3x --- #### What to look for in an attribution tool A few things matter more than a feature checklist: ##### Privacy and data ownership Prefer privacy-first attribution (fingerprint-based, no persistent device IDs) — and, ideally, a self-hostable, open-source option so user data never leaves your control. ##### Visibility past the install Attribution that only reports installs stops at the app-store door. Look for down-funnel tracking — in-app events, conversions, and revenue tied back to the link and campaign that drove them. ##### A fit for the channels you use Match the tool to where your growth comes from. If it's organic and owned — links, email, social, referrals, QR codes, and content — a deep-linking-first tool follows the user after the click on exactly those channels. --- #### Why LinkForty for Mobile Attribution? ##### Built for the Privacy-First Era LinkForty was designed specifically for post-ATT (post-iOS 14.5) mobile attribution: **Privacy-First:** - No IDFA/GAID required - Fingerprinting-based (70-80% accuracy) - GDPR compliant out of the box - No third-party data sharing - Self-hosted option (full data ownership) **Developer-Friendly:** - Open source (AGPL-3.0 license) - Simple SDK integration (5 minutes) - React Native, iOS, Android support - Full API access - Clear documentation **Cost-Effective:** - **Free** (self-hosted forever, open source) - Managed cloud with a free tier — see the [pricing page](https://linkforty.com/pricing) - No per-install pricing - No surprise overages **Full-Featured:** - Install attribution (fingerprint-based) - Deferred deep linking (70%+ accuracy) - Universal Links / App Links (auto-generated files) - Campaign tracking (UTM parameters) - Analytics dashboard - QR code generation - Webhooks for real-time events - Team collaboration (cloud) - Custom domains (cloud) --- ##### Getting Started with LinkForty ###### Cloud (Managed - Fastest) **1. Sign up** at [linkforty.com](https://linkforty.com) - No credit card required - Free tier available **2. Create your first link** - Set destination URLs (iOS App Store, Google Play, web) - Add UTM parameters for tracking - Set attribution window (default: 7 days) **3. Integrate SDK** (5 minutes) **React Native:** ```javascript import LinkForty from '@linkforty/mobile-sdk-react-native'; await LinkForty.init({ baseUrl: 'https://api.linkforty.com', apiKey: 'your-api-key', debug: __DEV__, }); // Check for deferred deep link on app open const installData = await LinkForty.getInstallData(); if (installData) { console.log('User came from:', installData.utmCampaign); console.log('UTM source:', installData.utmSource); // Navigate to content, award credits, etc. } ``` **4. Track events** ```javascript await LinkForty.trackEvent('purchase', { amount: 49.99, currency: 'USD', productId: 'premium-plan' }); ``` **5. View analytics** - Real-time click tracking - Install attribution - Campaign performance - Geographic breakdown - Device breakdown --- ###### Self-Hosted (Free Forever) **1. Install:** ```bash npm install @linkforty/core ``` **2. Set up database:** ```bash ### PostgreSQL (required) ### Redis (optional, for caching) ``` **3. Configure environment:** ```bash DATABASE_URL=postgresql://... REDIS_URL=redis://... ``` **4. Deploy:** - Docker (recommended) - Kubernetes - Any Node.js hosting **5. Integrate SDK** (same as cloud) [View Self-Hosting Guide →](https://docs.linkforty.com/getting-started/self-hosting/) --- #### Mobile Attribution Use Cases ##### 1. E-commerce **Goal:** Know which ads drive profitable customers **Setup:** - Create links for each campaign (Instagram, Facebook, Google) - Track installs with LinkForty SDK - Track purchase events **Insights:** ``` Instagram campaign: 500 installs, 50 purchases, $2,500 revenue Facebook campaign: 300 installs, 60 purchases, $3,000 revenue → Facebook has lower install volume but higher conversion rate → Shift budget to Facebook ``` --- ##### 2. Referral Programs **Goal:** Reward users who refer friends **Setup:** - Generate unique referral link per user - Track installs with deferred deep linking - Award credits to both referrer and referee **Example:** ``` User A shares: https://go.yourapp.com/invite/userA User B clicks, installs app LinkForty attributes install to User A's link App awards: - User A: $10 referral credit - User B: $10 signup bonus ``` --- ##### 3. Influencer Marketing **Goal:** Measure ROI per influencer **Setup:** - Create unique link for each influencer - Track installs per link - Calculate cost per install **Example:** ``` Influencer X: $5,000 payment, 1,000 installs ($5 CPI) Influencer Y: $3,000 payment, 400 installs ($7.50 CPI) → Influencer X is more cost-effective ``` --- ##### 4. Email Campaigns **Goal:** Track app installs from email **Setup:** - Embed LinkForty link in email - Add UTM parameters (utm_source=email, utm_campaign=spring-sale) - Track installs **Insights:** - Email subject line A: 200 installs - Email subject line B: 350 installs - Optimize future emails based on performance --- #### Next Steps Now that you understand mobile attribution, explore these related topics: - **[What is Deep Linking?](https://docs.linkforty.com/learn/what-is-deep-linking/)** - Understand deep linking basics - **[Deferred Deep Linking Explained](https://docs.linkforty.com/learn/deferred-deep-linking-explained/)** - How install attribution works - **[Attribution Without IDFA](https://docs.linkforty.com/tutorials/attribution-without-idfa/)** - Privacy-first attribution guide **Ready to start tracking?** [Get Started with LinkForty →](https://docs.linkforty.com/getting-started/quick-start/) [View SDK Integration Guide →](https://docs.linkforty.com/guides/attribution/sdk-integration/) --- #### Frequently Asked Questions ##### How accurate is fingerprint attribution? **LinkForty achieves 70-80% accuracy** using device fingerprinting. This is the industry standard for privacy-friendly attribution post-iOS 14.5. **Factors affecting accuracy:** - Higher accuracy: Unique devices, cellular networks, short attribution windows - Lower accuracy: Shared WiFi networks (offices, cafes), long time between click and install **Comparison:** - IDFA (with permission): 95-100% (but only ~30% of users grant permission) - Fingerprinting: 70-80% (works for everyone) ##### What is the difference between attribution and analytics? **Attribution:** Answers "What caused this install?" (which campaign, channel, ad) **Analytics:** Answers "What do users do in the app?" (screen views, events, retention) **LinkForty provides both:** - Attribution: Click-to-install tracking - Analytics: Event tracking, user behavior, campaign performance ##### Do I need an attribution platform if I only use one ad channel? **Even with one channel, attribution helps:** - Track campaign performance - Measure ROI - A/B test ad creatives - Optimize ad spend **Example:** Running only Instagram ads - Still need to know: Which ad creative drives installs? - Still need to track: Cost per install, install-to-purchase rate **Answer:** Yes, attribution is valuable even with one channel. ##### Can I use multiple attribution platforms? **Technically yes, but not recommended:** **Problems:** - Duplicated tracking (inflated metrics) - SDK conflicts - Increased app size - Higher costs (paying for multiple platforms) **Better approach:** Choose one platform (like LinkForty) and use it consistently. ##### How long should my attribution window be? **Industry standard:** 7 days **Customize based on product:** - **24 hours:** Direct response (flash sales, limited offers) - **7 days:** Standard (most products) - **14-30 days:** Consideration products (requires research) - **90 days:** High-ticket B2B (long sales cycles) **LinkForty default:** 7 days (configurable per link) ##### Is mobile attribution GDPR compliant? **Yes, when implemented correctly:** **GDPR-compliant approaches:** - Fingerprinting (not personal data under GDPR) - No persistent device IDs - User deletion support - Transparent privacy policy **LinkForty is GDPR compliant:** - Uses fingerprinting only - Supports data deletion requests (via API) - Self-hosted option (full data ownership) - No third-party sharing --- #### Summary Mobile attribution is essential for understanding what drives app installs and optimizing marketing spend. In the privacy-first era (post-iOS 14.5), fingerprinting-based attribution is the standard approach. **Key Takeaways:** - Attribution tells you **which campaigns drive installs** - Fingerprinting achieves **70-80% accuracy** without privacy concerns - Attribution windows (typically **7 days**) define matching timeframe - **LinkForty** brings deep-linking-first, privacy-first attribution to your organic and owned channels **Ready to start tracking installs?** LinkForty offers: - Privacy-friendly fingerprinting (70-80% accuracy) - Deferred deep linking - Campaign analytics - Open source & affordable [Get Started with LinkForty →](https://docs.linkforty.com/getting-started/quick-start/) --- **Questions?** - [View all guides](https://docs.linkforty.com/guides/links/creating-links/) - [GitHub Discussions](https://github.com/linkforty/core/discussions) - [Contact Support](mailto:inbox@linkforty.com) --- ## Deferred Deep Linking Explained - Complete Guide Source: https://docs.linkforty.com/learn/deferred-deep-linking-explained/ Deferred deep linking is a mobile app technique that routes users to specific content **after** they install your app, remembering the context of the original link they clicked before installing. It's the "magic" that makes mobile user acquisition seamless. #### The Problem Without Deferred Deep Linking **Standard scenario (no deferred deep linking):** 1. User sees Instagram ad for a specific product (winter jacket) 2. User clicks ad → Redirected to App Store (app not installed) 3. User downloads and installs app 4. User opens app → Lands on home screen 5. User must search for winter jacket manually 6. Context lost, poor experience 7. User likely abandons (70% drop-off) **Result:** You paid for the ad, but lost the sale. --- **With deferred deep linking:** 1. User sees Instagram ad for winter jacket 2. User clicks ad → System "remembers" this was for winter jacket 3. User downloads and installs app 4. User opens app → **Automatically routed to winter jacket product page** 5. Seamless experience 6. User sees exactly what they clicked 7. 3-5x higher conversion rate **Result:** You paid for the ad, and made the sale. --- #### How Deferred Deep Linking Works ##### The Complete Flow ``` 1. User clicks link → Link: https://go.yourapp.com/products/winter-jacket 2. Platform detects: App not installed → Creates "fingerprint" of user's device → Stores: {link: "products/winter-jacket", fingerprint: "abc123"} → Redirects to App Store 3. User installs app from App Store 4. User opens app for first time → App SDK contacts attribution platform → Sends device fingerprint: "abc123" 5. Platform matches fingerprint → Match found! Original link was "products/winter-jacket" → Returns link data to app 6. App receives link data → Routes user to winter jacket product page → User sees the product they originally clicked ``` **Time between click and install:** Can be seconds to days (within attribution window) --- #### Device Fingerprinting: The Technology Behind It ##### What is a Device Fingerprint? A **device fingerprint** is a combination of device characteristics that, together, create a unique identifier **without** using persistent device IDs (like IDFA or GAID). ##### Fingerprint Components **Data collected at click time:** ```json { "ip": "192.168.1.100", "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X)", "screen": { "width": 1179, "height": 2556, "density": 3 }, "language": "en-US", "timezone": "America/Los_Angeles", "platform": "iOS", "os_version": "17.2", "device_model": "iPhone 15 Pro", "carrier": "Verizon", "timestamp": "2025-06-15T10:30:00Z" } ``` **Data collected at app open (install time):** ```json { // Same data points collected when app opens "ip": "192.168.1.100", // Same WiFi/cellular network "userAgent": "...", // Same device "screen": {...}, // Same screen size "language": "en-US", // Same settings "timezone": "America/Los_Angeles", // Same timezone // etc. } ``` ##### Matching Algorithm **Step 1: Compare fingerprints** ``` Click fingerprint vs Install fingerprint Matches: IP address: 192.168.1.100 (same) Screen: 1179x2556 (same) Language: en-US (same) Timezone: America/Los_Angeles (same) OS version: 17.2 (same) Carrier: Verizon (same) Match confidence: 95% ``` **Step 2: Check attribution window** ``` Click time: 2025-06-15 10:30:00 Install time: 2025-06-15 11:45:00 Time diff: 1 hour 15 minutes Attribution window: 7 days (168 hours) Within window → Match valid ``` **Step 3: Return result** ``` Match found! Original link: https://go.yourapp.com/products/winter-jacket Link data: { "product_id": "winter-jacket", "utm_source": "instagram", "utm_campaign": "winter-sale", "discount_code": "WINTER20" } App receives this data → Routes to product + applies discount ``` --- #### Accuracy Expectations ##### Industry Standard Accuracy **Fingerprinting accuracy:** 70-80% **Why not 100%?** **Reasons for false negatives (missed matches):** 1. **IP address changed** between click and install - User switched from WiFi to cellular - User left home/office - Dynamic IP reassigned 2. **Shared IP addresses** - Office WiFi (100+ devices on same IP) - Public WiFi (coffee shops, airports) - Mobile carrier NAT (multiple users share IP) 3. **Attribution window expired** - User clicked 8 days ago (7-day window) 4. **Device settings changed** - User changed language/timezone - User updated OS between click and install **Reasons for false positives (wrong matches - rare):** 1. Two users with identical device characteristics on same IP - Extremely rare (1-2% of matches) - More common on shared WiFi networks --- ##### Accuracy by Scenario | Scenario | Accuracy | Explanation | |----------|----------|-------------| | **Same WiFi, short time (<1 hour)** | 90-95% | Ideal conditions | | **Same cellular network, <24 hours** | 80-85% | Cellular IPs more unique | | **Different network (WiFi→Cellular)** | 60-70% | IP changed, other factors match | | **Shared WiFi (office, cafe)** | 50-60% | Multiple devices, same IP | | **Long time (3-7 days)** | 65-75% | IP may have changed | | **Beyond attribution window** | 0% | Expired, labeled "organic" | **LinkForty achieves:** 70-80% average accuracy (industry standard) --- #### Privacy & Compliance ##### Why Fingerprinting is Privacy-Friendly **No persistent device IDs:** - No IDFA (iOS Identifier for Advertisers) - No GAID (Google Advertising ID) - No permanent tracking identifiers **No personal data:** - No names, emails, phone numbers - No cross-app tracking - No long-term user profiling **Temporary matching:** - Fingerprints used only for attribution window (7 days) - After window expires, data can be purged - No persistent user identity --- ##### GDPR Compliance **Why fingerprinting is GDPR-compliant:** 1. **Not personal data** under GDPR definition - Device characteristics ≠ personally identifiable information - Cannot identify a specific individual - Temporary, used only for attribution 2. **Legitimate interest** legal basis - Attribution is necessary business function - Users benefit from better experience - Privacy impact is minimal 3. **User rights supported** - Right to deletion (delete click/install records) - Right to data export (export attribution data) - Right to object (can opt-out) **LinkForty's approach:** - GDPR-compliant out of the box - Supports data deletion via API - Clear privacy policy - Self-hosted option (full data ownership) --- #### Deferred Deep Linking Use Cases ##### 1. Product-Specific Advertising **Scenario:** E-commerce app running ads for specific products **Setup:** ``` Instagram ad for "Winter Jacket" → Link: https://go.store.com/products/winter-jacket-2024 → User without app clicks → Redirected to App Store → User installs, opens app → Deferred deep link routes to winter jacket page ``` **Benefits:** - User sees exact product they clicked - 3-5x higher purchase rate - Better ad ROI **Without deferred deep linking:** - User lands on home screen - Must search for winter jacket - 70% abandon before finding it --- ##### 2. Referral Programs **Scenario:** User invites friend to app **Setup:** ``` User A shares referral link → Link: https://go.app.com/invite/userA?code=FRIEND20 → User B (friend) clicks, installs app → Deferred deep link knows: - Referred by userA - Discount code: FRIEND20 → App automatically: - Credits userA with referral bonus - Applies FRIEND20 discount to User B ``` **Benefits:** - Automatic referral tracking - Rewards applied instantly - No manual code entry needed --- ##### 3. Email Campaigns (New User Acquisition) **Scenario:** Email campaign targeting non-users **Setup:** ``` Email: "Get 20% off your first order" → Link: https://go.app.com/signup?promo=EMAIL20&source=newsletter → Recipient clicks (doesn't have app) → Redirected to App Store → Installs app → Deferred deep link: - Applies EMAIL20 discount automatically - Tracks install came from email newsletter ``` **Benefits:** - Seamless onboarding - Discount applied automatically - Track email campaign ROI --- ##### 4. Influencer Marketing **Scenario:** Each influencer gets unique link **Setup:** ``` Influencer X posts: https://go.app.com/influencer-x Influencer Y posts: https://go.app.com/influencer-y User clicks Influencer X link → Installs Deferred deep link attributes install to Influencer X Analytics show: - Influencer X: 1,000 installs - Influencer Y: 400 installs → Know which influencer drives more installs ``` **Benefits:** - Precise attribution per influencer - Calculate ROI per partnership - Optimize influencer spend --- ##### 5. QR Code Campaigns **Scenario:** QR codes on product packaging, posters, business cards **Setup:** ``` QR code on product packaging → Encodes: https://go.app.com/product/xyz?source=package → User scans QR code → Doesn't have app → Sent to App Store → Installs app → Deferred deep link routes to product xyz → Tracks install came from product packaging ``` **Benefits:** - Offline-to-online attribution - Measure packaging campaign effectiveness - Seamless product discovery --- #### Implementation Approaches ##### 1. DIY (Build Your Own) **What you need to build:** 1. **Link generation system** - Create short links - Store destination URLs - Track UTM parameters 2. **Fingerprinting logic** - Collect device characteristics at click time - Store fingerprints with link data - Set TTL (e.g., 7 days) 3. **SDK for mobile apps** - Collect device characteristics at app open - Send to server for matching - Receive matched link data - Route user to destination 4. **Matching algorithm** - Compare click fingerprint vs install fingerprint - Calculate confidence score - Return best match 5. **Analytics system** - Track clicks, installs, conversions - Attribution reporting - Campaign performance **Time investment:** 3-6 weeks of development **Challenges:** - Fingerprinting accuracy optimization - Handling edge cases (shared IPs, network changes) - Cross-platform SDK (iOS, Android, React Native) - Scale (millions of clicks/installs) --- ##### 2. Use an Attribution Platform **What platforms provide:** - Pre-built fingerprinting (optimized for accuracy) - Mobile SDKs (iOS, Android, React Native) - Link management UI - Analytics dashboard - API access [**LinkForty**](https://linkforty.com) provides all of this — deferred deep linking, fingerprint attribution, mobile SDKs, link management, and analytics — as an open-source, privacy-first platform you can self-host or run as managed Cloud. **Time investment:** 1-2 days of SDK integration --- #### LinkForty's Deferred Deep Linking ##### How LinkForty Implements It **1. Link Creation** ``` Create link in LinkForty dashboard: - Destination: /products/winter-jacket - iOS URL: App Store link - Android URL: Google Play link - UTM params: source=instagram, campaign=winter-sale Short link generated: https://go.yourapp.com/abc123 ``` **2. User Clicks (App Not Installed)** ``` User clicks https://go.yourapp.com/abc123 LinkForty: 1. Creates device fingerprint 2. Stores: { link: "/products/winter-jacket", fingerprint: {...}, utmParameters: {...}, timestamp: "2025-06-15T10:30:00Z" } 3. Redirects to App Store ``` **3. User Installs and Opens App** ``` App opens, LinkForty SDK initializes: LinkForty.init({ baseUrl: 'https://api.linkforty.com', apiKey: 'your-api-key' }); const installData = await LinkForty.getInstallData(); ``` **4. Matching Happens** ``` SDK sends device fingerprint to LinkForty LinkForty matches: Click fingerprint → Install fingerprint Confidence: 85% (within 7-day window) Returns: { "attributed": true, "deepLinkPath": "/products/winter-jacket", "utmSource": "instagram", "utmCampaign": "winter-sale", "linkId": "abc123" } ``` **5. App Routes User** ```javascript if (installData) { // User came from a link, route to destination if (installData.deepLinkPath) { navigation.navigate(installData.deepLinkPath); } // Track the conversion analytics.track('deferred_deep_link_matched', { campaign: installData.utmCampaign, source: installData.utmSource }); } ``` --- ##### LinkForty Accuracy **LinkForty achieves 70-80% attribution accuracy:** **Fingerprint data points used:** - IP address - User agent (device model, OS version) - Screen resolution - Language settings - Timezone - Carrier information (mobile) **Optimization techniques:** - IP address proximity matching (if exact IP changed but in same subnet) - Confidence scoring (weight factors differently) - Time decay (clicks closer in time weighted higher) - Deduplication (prevent double-attribution) **Result:** Industry-standard 70-80% accuracy, privacy-compliant --- ##### Getting Started with LinkForty Deferred Deep Linking ###### Cloud (5-Minute Setup) **1. Create account** at [linkforty.com](https://linkforty.com) **2. Create your first link** ``` Destination: /products/winter-jacket iOS URL: https://apps.apple.com/app/yourapp/id123456 Android URL: https://play.google.com/store/apps/details?id=com.yourapp Attribution window: 7 days ``` **3. Install SDK** **React Native:** ```bash npm install @linkforty/mobile-sdk-react-native ``` ```javascript import LinkForty from '@linkforty/mobile-sdk-react-native'; // Initialize in App.tsx await LinkForty.init({ baseUrl: 'https://api.linkforty.com', apiKey: 'your-api-key', debug: __DEV__, }); // Check for deferred deep link on app launch const installData = await LinkForty.getInstallData(); if (installData) { console.log('Install attributed to:', installData.utmCampaign); // Route to destination if (installData.deepLinkPath) { navigation.navigate(installData.deepLinkPath); } // Apply discount code if present if (installData.discountCode) { applyDiscount(installData.discountCode); } // Award referral credit if present if (installData.referrerId) { creditReferrer(installData.referrerId); } } ``` **4. Share your link** ``` Use https://go.yourapp.com/abc123 in: - Instagram ads - Email campaigns - Influencer posts - QR codes ``` **5. Track conversions** ```javascript // Track purchase event await LinkForty.trackEvent('purchase', { amount: 49.99, productId: 'winter-jacket', currency: 'USD' }); // View attribution in LinkForty dashboard: // - Which campaigns drive installs // - Which campaigns drive purchases // - ROI per campaign ``` [View Complete SDK Integration Guide →](https://docs.linkforty.com/guides/attribution/sdk-integration/) --- ###### Self-Hosted (Free) **1. Install LinkForty Core** ```bash npm install @linkforty/core ``` **2. Set up infrastructure** ```bash ### PostgreSQL (required) ### Redis (optional, improves performance) ``` **3. Configure** ```bash DATABASE_URL=postgresql://... REDIS_URL=redis://... JWT_SECRET=your-secret-key ATTRIBUTION_WINDOW=168 # hours (7 days) ``` **4. Deploy** - Docker / Kubernetes / any Node.js hosting **5. Integrate SDK** (same as cloud) [View Self-Hosting Guide →](https://docs.linkforty.com/getting-started/self-hosting/) --- #### Best Practices for Deferred Deep Linking ##### 1. Always Provide Fallback Content **Don't assume attribution always works (70-80% match rate):** ```javascript const installData = await LinkForty.getInstallData(); if (installData) { // Matched! Route to specific content if (installData.deepLinkPath) { navigation.navigate(installData.deepLinkPath); } } else { // No match (organic install or match failed) // Show onboarding flow navigation.navigate('Onboarding'); } ``` **Never:** Crash or show error if attribution fails **Always:** Handle both matched and unmatched scenarios gracefully --- ##### 2. Set Appropriate Attribution Windows **Choose based on your product:** - **24 hours:** Flash sales, time-sensitive offers - **7 days:** Standard (most products) - **14-30 days:** Consideration products (research required) **Don't:** - Use 90+ day windows (too much false matching) - Use <1 hour windows (users need time to install) --- ##### 3. Test Both Scenarios **Test with app installed:** ``` 1. Have app installed 2. Click link 3. App should open directly (no install) 4. Route to content ``` **Test without app installed:** ``` 1. Uninstall app 2. Click link 3. Redirected to App Store/Google Play 4. Install app 5. Open app 6. Verify deferred deep link works (routes to content) ``` **Test edge cases:** - Switch from WiFi to cellular between click and install - Wait several hours between click and install - Click multiple links before installing --- ##### 4. Track Everything **Don't just track installs, track the full funnel:** ```javascript // Click event (tracked automatically by platform) // Install event (tracked automatically by SDK) // Track when deferred deep link matched if (installData) { analytics.track('deferred_deep_link_matched', { campaign: installData.utmCampaign, timeToInstall: installData.clickToInstallSeconds }); } // Track when user reaches destination content analytics.track('deferred_deep_link_content_viewed', { destination: installData.deepLinkPath }); // Track conversions analytics.track('purchase', { attributedTo: installData.utmCampaign }); ``` **Calculate:** - Click → Install rate - Install → Match rate (should be 70-80%) - Match → Content view rate - Content view → Conversion rate - Overall click → Purchase rate --- ##### 5. Use Custom Data for Personalization **Pass custom data through deferred deep links:** ``` Link: https://go.app.com/signup? discount=WINTER20& referrer=user123& segment=vip& product_id=winter-jacket ``` **App receives:** ```javascript const installData = await LinkForty.getInstallData(); // Personalize experience if (installData) { // Apply discount if (installData.discount) { applyDiscount(installData.discount); } // Credit referrer if (installData.referrer) { creditReferrer(installData.referrer); } // Personalize UI if (installData.segment === 'vip') { showVIPWelcome(); } // Pre-fill shopping cart if (installData.productId) { addToCart(installData.productId); } } ``` --- #### Alternatives to Deferred Deep Linking ##### 1. Manual Code Entry **User experience:** ``` 1. User clicks ad 2. Redirected to App Store 3. Installs app 4. Opens app 5. Asked to enter code: "WINTER20" 6. Manually types code 7. Gets routed to content ``` **Problems:** - High friction (50% abandon) - Typos common - Poor user experience **When to use:** Never (deferred deep linking is always better) --- ##### 2. Email/SMS Verification **User experience:** ``` 1. User clicks ad 2. Asked to enter email before redirect 3. Email stored with link data 4. Redirected to App Store 5. Installs app 6. Asked to enter same email 7. Email matched → Routed to content ``` **Problems:** - Two-step process (high friction) - Users may use different email - Privacy concerns (collecting email before install) **When to use:** High-value flows where extra friction acceptable (enterprise B2B) --- ##### 3. Universal Links (iOS Only) **User experience:** ``` 1. User clicks Universal Link (app already installed) 2. App opens directly (no deferred deep linking needed) 3. Routes to content ``` **Limitations:** - 100% accurate - Only works if app already installed - Doesn't work for new user acquisition **When to use:** Combine with deferred deep linking: - If app installed → Universal Links (100% accurate) - If app NOT installed → Deferred deep linking (70-80% accurate) --- #### Summary Deferred deep linking is critical for mobile user acquisition. It enables seamless experiences by routing users to specific content after they install your app, even though the installation process breaks the direct link context. **Key Takeaways:** - Deferred deep linking "remembers" the link through the install process - Fingerprinting achieves **70-80% accuracy** without privacy concerns - Essential for product-specific ads, referrals, influencer marketing - **LinkForty** provides production-ready deferred deep linking, open source and privacy-first **Ready to implement deferred deep linking?** LinkForty offers: - 70-80% attribution accuracy (fingerprinting) - 5-minute SDK integration - Privacy-first (GDPR compliant) - Open source & affordable [Get Started with LinkForty →](https://docs.linkforty.com/getting-started/quick-start/) [View SDK Integration Guide →](https://docs.linkforty.com/guides/attribution/sdk-integration/) --- #### Next Steps - **[What is Deep Linking?](https://docs.linkforty.com/learn/what-is-deep-linking/)** - Understand basic deep linking - **[What is Mobile Attribution?](https://docs.linkforty.com/learn/what-is-mobile-attribution/)** - Learn about attribution in general - **[Attribution Without IDFA](https://docs.linkforty.com/tutorials/attribution-without-idfa/)** - Privacy-first attribution guide - **[SDK Integration Guide](https://docs.linkforty.com/guides/attribution/sdk-integration/)** - Implement LinkForty SDK --- **Questions?** - [View all guides](https://docs.linkforty.com/guides/links/creating-links/) - [GitHub Discussions](https://github.com/linkforty/core/discussions) - [Contact Support](mailto:inbox@linkforty.com) --- ## Universal Links vs App Links - iOS vs Android Deep Linking Source: https://docs.linkforty.com/learn/universal-links-vs-app-links/ Universal Links (iOS) and App Links (Android) are the modern, platform-native approaches to deep linking. Unlike custom URL schemes, they use standard **HTTPS URLs** that work as both web links and app deep links, providing a seamless experience across all scenarios. #### Quick Comparison | Feature | Universal Links (iOS) | App Links (Android) | |-----------------------|-----------------------------------|------------------------------------| | **Platform** | iOS 9+ | Android 6.0+ (API 23+) | | **URL Format** | `https://yourdomain.com/path` | `https://yourdomain.com/path` | | **Verification File** | `apple-app-site-association` | `assetlinks.json` | | **File Location** | `/.well-known/` | `/.well-known/` | | **Fallback** | Opens Safari if app not installed | Opens browser if app not installed | | **Requires Domain** | Yes | Yes | | **HTTPS Required** | Yes | Yes | | **User Prompt** | No (seamless) | No (if verified) | **Key Similarity:** Both use standard HTTPS URLs that work everywhere **Key Difference:** Implementation details and verification processes differ --- #### Why Universal Links / App Links? ##### The Problem with Custom URL Schemes **Old approach (custom schemes):** ``` myapp://products/123 ``` **Problems:** - Only works if app installed (broken link otherwise) - Scheme conflicts (multiple apps can claim same scheme) - Browser security warnings - Can't use as regular web links - Poor sharing experience --- ##### The Modern Approach (HTTPS Links) **Universal/App Links:** ``` https://go.yourapp.com/products/123 ``` **Benefits:** - Works as regular web link (shareable everywhere) - If app installed → Opens app - If app NOT installed → Opens website - No broken links ever - No scheme conflicts (you own the domain) - Better user experience - SEO-friendly (search engines index them) --- #### Universal Links (iOS) ##### What Are Universal Links? Universal Links are Apple's implementation of HTTPS-based deep linking, introduced in iOS 9 (2015). They allow you to associate your iOS app with your website domain. ##### How Universal Links Work ``` 1. User clicks: https://go.yourapp.com/products/123 2. iOS checks: - Is an app associated with "go.yourapp.com"? - Does that app have Universal Links enabled? 3a. If YES: → App opens directly → App receives URL: /products/123 → App routes user to product page 3b. If NO (app not installed): → Safari opens → Loads: https://go.yourapp.com/products/123 → Website displays product page ``` **Result:** Seamless experience whether app is installed or not --- ##### Universal Links Requirements ###### 1. Owned Domain You must own a domain (e.g., `go.yourapp.com` or `yourapp.com`) **Options:** - Use main domain: `yourapp.com` - Use subdomain: `go.yourapp.com`, `link.yourapp.com`, `app.yourapp.com` **Recommendation:** Use subdomain dedicated to links (easier to manage) --- ###### 2. HTTPS Hosting Your domain must be accessible via HTTPS. **Requirements:** - Valid SSL certificate - HTTPS enabled - Publicly accessible (not localhost) --- ###### 3. Apple App Site Association (AASA) File A JSON file that tells iOS which app is associated with your domain. **File name:** `apple-app-site-association` (no .json extension) **Location:** `https://yourdomain.com/.well-known/apple-app-site-association` **Example:** ```json { "applinks": { "apps": [], "details": [ { "appID": "TEAM1234.com.yourcompany.yourapp", "paths": [ "/products/*", "/offers/*", "NOT /admin/*" ] } ] } } ``` **Components:** - `appID`: Team ID + Bundle ID (format: `TEAMID.BUNDLEID`) - `paths`: Which URL paths should open the app - `"/products/*"` → Matches all product pages - `"NOT /admin/*"` → Excludes admin pages (stay in browser) --- ###### 4. Associated Domains in Xcode Enable Associated Domains capability and add your domain. **In Xcode:** 1. Select your project → Target → Signing & Capabilities 2. Click "+ Capability" 3. Add "Associated Domains" 4. Add domain with prefix: `applinks:go.yourapp.com` **Example:** ``` applinks:go.yourapp.com applinks:yourapp.com ``` **No HTTPS prefix needed** (iOS adds it automatically) --- ###### 5. Handle Universal Links in Code Implement AppDelegate method to receive URLs. **Swift (UIKit):** ```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 the URL // url.path will be something like "/products/123" handleUniversalLink(url: url) return true } func handleUniversalLink(url: URL) { let path = url.path if path.hasPrefix("/products/") { let productID = path.replacingOccurrences(of: "/products/", with: "") // Navigate to product screen navigateToProduct(id: productID) } else if path.hasPrefix("/offers/") { // Navigate to offers navigateToOffers() } } ``` **SwiftUI:** ```swift @main struct YourApp: App { var body: some Scene { WindowGroup { ContentView() .onOpenURL { url in handleUniversalLink(url: url) } } } } ``` --- ##### Universal Links Verification Process **When does iOS download the AASA file?** - When app is first installed - When app is updated - Periodically (iOS manages this) **Important:** - AASA file is **cached by iOS** (changes can take hours/days to propagate) - For testing, uninstall and reinstall app to force re-download --- ##### Universal Links Troubleshooting ###### Problem: Universal Links Not Working **Checklist:** 1. **Is AASA file accessible?** ```bash curl https://go.yourapp.com/.well-known/apple-app-site-association ``` - Should return JSON (200 OK) - No redirects (301/302 not allowed) - HTTPS required - Correct Content-Type header (application/json or no Content-Type) 2. **Is Team ID correct?** - Find in Apple Developer account - Format: 10 characters (e.g., `AB12CD34EF`) - Common mistake: Using wrong Team ID 3. **Is Bundle ID correct?** - Must match exactly (case-sensitive) - Format: `com.yourcompany.yourapp` 4. **Is Associated Domains configured in Xcode?** - Check Signing & Capabilities - Should see: `applinks:go.yourapp.com` 5. **Is app code implemented?** - Check AppDelegate method exists - Add logging to verify it's being called 6. **Is iOS caching old AASA?** - Uninstall app completely - Reinstall from Xcode - Test again 7. **Test with Apple's validator** - Visit: https://search.validator.apple.com/ - Enter your URL - Check for errors --- ###### Problem: Links Open in Safari Instead of App **Reasons:** 1. **User manually disabled Universal Links** - When user long-presses a Universal Link, they can choose "Open in Safari" - iOS remembers this preference - **Fix:** Uninstall and reinstall app 2. **Link opened from same domain** - If user is already on `go.yourapp.com` website - And clicks a link to `go.yourapp.com/products/123` - iOS keeps them in Safari (user intent to stay on web) - **Fix:** Use a different domain for website vs links 3. **AASA file has errors** - Check with Apple's validator - Ensure JSON is valid --- #### App Links (Android) ##### What Are App Links? App Links are Android's implementation of HTTPS-based deep linking, introduced in Android 6.0 (2015). They work similarly to Universal Links but with Android-specific setup. ##### How App Links Work ``` 1. User clicks: https://go.yourapp.com/products/123 2. Android checks: - Is an app associated with "go.yourapp.com"? - Is the app verified for this domain? 3a. If YES (verified): → App opens directly → App receives URL: https://go.yourapp.com/products/123 → App routes user to product page 3b. If NO (app not installed or not verified): → Browser opens → Loads: https://go.yourapp.com/products/123 → Website displays product page ``` --- ##### App Links Requirements ###### 1. Owned Domain Same as iOS - you must own the domain. --- ###### 2. HTTPS Hosting Same as iOS - HTTPS required with valid SSL certificate. --- ###### 3. Digital Asset Links File A JSON file that verifies your app is authorized to open links for this domain. **File name:** `assetlinks.json` **Location:** `https://yourdomain.com/.well-known/assetlinks.json` **Example:** ```json [ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.yourcompany.yourapp", "sha256_cert_fingerprints": [ "FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C" ] } } ] ``` **Components:** - `relation`: Always `["delegate_permission/common.handle_all_urls"]` - `package_name`: Android package name (e.g., `com.yourcompany.yourapp`) - `sha256_cert_fingerprints`: SHA-256 fingerprint of your app signing key --- ###### 4. Get SHA-256 Fingerprint **From Google Play Console (recommended for production apps):** 1. Open [Google Play Console](https://play.google.com/console) 2. Select your app 3. Navigate to **Test and Release** → **App Integrity** → **Play app signing** → **Settings** 4. Copy the **SHA-256 certificate fingerprint** from the **App signing key certificate** section **Important:** Use the **App signing key** fingerprint, not the Upload key fingerprint. Google Play re-signs your app with the app signing key, so this is the fingerprint Android devices will verify against. **For debug builds (local testing only):** ```bash keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android ``` **For release builds (if not using Play App Signing):** ```bash keytool -list -v -keystore /path/to/your-release-key.keystore -alias your-key-alias ``` **Output will include:** ``` SHA256: FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C ``` Copy this into `assetlinks.json` (or paste it into the LinkForty dashboard App Configuration). **Important:** Debug and release builds have different fingerprints! --- ###### 5. Configure AndroidManifest.xml Add intent filters for your domain URLs. **AndroidManifest.xml:** ```xml ``` **Key attribute:** `android:autoVerify="true"` - Tells Android to verify domain association - Without this, user gets prompted to choose browser vs app **Multiple domains:** ```xml ``` --- ###### 6. Handle App Links in Code **Kotlin (MainActivity):** ```kotlin override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Handle intent 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 (Intent.ACTION_VIEW == action && data != null) { // data.path will be something like "/products/123" handleDeepLink(data) } } private fun handleDeepLink(uri: Uri) { val path = uri.path when { path?.startsWith("/products/") == true -> { val productID = path.replace("/products/", "") // Navigate to product screen navigateToProduct(productID) } path?.startsWith("/offers/") == true -> { // Navigate to offers navigateToOffers() } } } ``` **React Native:** ```javascript import { Linking } from 'react-native'; useEffect(() => { // Handle initial URL (app was closed) Linking.getInitialURL().then(url => { if (url) { handleDeepLink(url); } }); // Handle URL when app is running const subscription = Linking.addEventListener('url', ({ url }) => { handleDeepLink(url); }); return () => subscription.remove(); }, []); const handleDeepLink = (url: string) => { const path = url.replace('https://go.yourapp.com', ''); if (path.startsWith('/products/')) { const productID = path.replace('/products/', ''); navigation.navigate('Product', { id: productID }); } }; ``` --- ##### App Links Verification Process **When does Android verify?** - When app is installed - User can manually verify in Settings **Check verification status:** ```bash adb shell pm get-app-links com.yourcompany.yourapp ``` **Output:** ``` com.yourcompany.yourapp: ID: ... Signatures: ... Domain verification state: go.yourapp.com: verified ← This is what you want ``` **If shows "none" or "legacy":** - assetlinks.json not accessible - SHA-256 fingerprint mismatch - android:autoVerify not set to true --- ##### App Links Troubleshooting ###### Problem: App Links Not Working **Checklist:** 1. **Is assetlinks.json accessible?** ```bash curl https://go.yourapp.com/.well-known/assetlinks.json ``` - Should return JSON (200 OK) - HTTPS required - No redirects 2. **Is package name correct?** - Check in AndroidManifest.xml - Must match exactly 3. **Is SHA-256 fingerprint correct?** - For Play Store apps: check Google Play Console → Test and Release → App Integrity → Play app signing → Settings - Use the **App signing key** fingerprint (not the Upload key) - **Common mistake:** Using debug fingerprint for release build, or using the upload key fingerprint instead of the app signing key fingerprint 4. **Is android:autoVerify="true" set?** - Check AndroidManifest.xml intent filter 5. **Force verification:** ```bash adb shell pm verify-app-links --re-verify com.yourcompany.yourapp ``` 6. **Check verification status:** ```bash adb shell pm get-app-links com.yourcompany.yourapp ``` 7. **Test with adb:** ```bash adb shell am start -a android.intent.action.VIEW -d "https://go.yourapp.com/products/123" ``` - Should open your app (not browser) --- ###### Problem: Disambiguation Dialog Appears **When this happens:** ``` User clicks link → Android shows: "Open with:" [ Chrome ] [ Your App ] ``` **Causes:** 1. `android:autoVerify="true"` not set 2. Domain not verified (assetlinks.json issue) 3. User manually cleared defaults **Fix:** Ensure verification completes successfully --- #### Universal Links vs App Links: Key Differences ##### 1. Verification File Format **Universal Links (AASA):** ```json { "applinks": { "apps": [], "details": [{ "appID": "TEAMID.com.yourcompany.yourapp", "paths": ["/products/*"] }] } } ``` **App Links (assetlinks):** ```json [{ "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.yourcompany.yourapp", "sha256_cert_fingerprints": ["FA:C6:17:..."] } }] ``` --- ##### 2. Path Filtering **Universal Links:** Can specify which paths open app ```json "paths": [ "/products/*", // Opens app "/offers/*", // Opens app "NOT /admin/*" // Stays in browser ] ``` **App Links:** All paths under domain open app (configured in AndroidManifest) ```xml ``` --- ##### 3. Caching **Universal Links:** - iOS caches AASA file - Changes can take hours/days - Must uninstall/reinstall to force refresh **App Links:** - Android verifies on install - Can manually re-verify - Faster to test changes --- ##### 4. Debugging **Universal Links:** - Use Apple's validator: https://search.validator.apple.com/ - Check Console app for logs - More opaque (harder to debug) **App Links:** - Use adb commands to check status - Force re-verification - Easier to debug --- #### Hosting Both Files **You need BOTH files for cross-platform apps:** ``` https://go.yourapp.com/ .well-known/ apple-app-site-association (iOS) assetlinks.json (Android) ``` **Static file hosting (recommended):** - AWS S3 + CloudFront - Netlify - Vercel - GitHub Pages **Web server configuration:** - Ensure HTTPS enabled - No redirects for .well-known paths - Correct MIME types (application/json or no Content-Type) --- #### How LinkForty Simplifies This ##### The Manual Approach is Complex **Without a platform:** 1. Set up domain and HTTPS hosting 2. Generate AASA file (get Team ID, Bundle ID, paths) 3. Generate assetlinks.json (get package name, SHA-256 fingerprint) 4. Host both files at `/.well-known/` 5. Configure Xcode (Associated Domains) 6. Configure AndroidManifest.xml (intent filters) 7. Implement handling code in both iOS and Android 8. Test thoroughly on both platforms 9. Debug issues (often opaque error messages) **Time:** 1-2 weeks for first implementation --- ##### LinkForty Auto-Generates Verification Files **With LinkForty:** 1. **Configure once in dashboard:** - iOS Team ID: `TEAM1234` (from [Apple Developer Portal](https://developer.apple.com/account) → Membership) - iOS Bundle ID: `com.yourapp` - Android Package Name: `com.yourapp` - Android SHA-256 Fingerprint: `FA:C6:17:...` (from [Google Play Console](https://play.google.com/console) → Test and Release → App Integrity → Play app signing → Settings) - Domain: `go.yourapp.com` 2. **LinkForty automatically generates:** - `/.well-known/apple-app-site-association` - `/.well-known/assetlinks.json` 3. **Point your domain to LinkForty:** - CNAME: `go.yourapp.com` → `your-org.linkforty.app` - Or use LinkForty's hosting 4. **Implement SDK (handles all platform-specific code):** ```javascript import LinkForty from '@linkforty/mobile-sdk-react-native'; await LinkForty.init({ baseUrl: 'https://api.linkforty.com', apiKey: 'your-api-key' }); // LinkForty SDK handles Universal Links + App Links automatically ``` **Time:** 15 minutes setup + testing --- #### Best Practices ##### 1. Use Dedicated Subdomain **Don't:** Use your main website domain ``` https://yourapp.com/products/123 ``` **Problem:** If user is browsing yourapp.com website, links won't open app (iOS keeps them in browser) **Do:** Use dedicated subdomain ``` https://go.yourapp.com/products/123 https://link.yourapp.com/products/123 https://app.yourapp.com/products/123 ``` **Benefit:** Clear separation between website and app links --- ##### 2. Keep Verification Files Accessible **Don't:** - Add authentication to `/.well-known/` paths - Use redirects (301/302) - Change files frequently **Do:** - Allow public access (no auth) - Direct access (no redirects) - Keep files stable (changes take time to propagate) --- ##### 3. Test on Real Devices **Don't:** Only test on simulator/emulator - Simulators don't fully emulate Universal Links behavior - Emulators may have different network setup **Do:** Test on real devices - iPhone (iOS) - Android phone (Android) - Test both Wi-Fi and cellular - Test with app installed and uninstalled --- ##### 4. Implement Proper Fallbacks **Always handle:** - App not installed → Open website - URL path not recognized → Show home screen or error - Network failure → Cache last known state --- ##### 5. Monitor Verification Status **iOS:** Check with Apple's validator regularly **Android:** Check verification status with adb --- #### Summary Universal Links (iOS) and App Links (Android) are the modern standard for mobile deep linking. They use HTTPS URLs that work everywhere, providing seamless experiences whether the app is installed or not. **Key Takeaways:** - Both platforms use **HTTPS URLs** (not custom schemes) - Require **domain ownership** and **HTTPS hosting** - Need **verification files** hosted at `/.well-known/` - Provide **graceful fallbacks** to web - **LinkForty auto-generates verification files** (saves 1-2 weeks of setup) **Ready to implement?** LinkForty handles all the complexity: - Auto-generates AASA + assetlinks.json files - Cross-platform SDK (handles both iOS and Android) - Testing tools and validation - 15-minute setup vs 1-2 weeks manual [Get Started with LinkForty →](https://docs.linkforty.com/getting-started/quick-start/) [View Implementation Tutorial →](https://docs.linkforty.com/learn/universal-links-vs-app-links/) --- #### Next Steps **Learn:** - **[What is Deep Linking?](https://docs.linkforty.com/learn/what-is-deep-linking/)** - Deep linking fundamentals - **[Deferred Deep Linking](https://docs.linkforty.com/learn/deferred-deep-linking-explained/)** - Post-install attribution **Implement:** - **[Universal Links vs App Links](https://docs.linkforty.com/learn/universal-links-vs-app-links/)** - iOS and Android implementation details - **[SDK Integration Guide](https://docs.linkforty.com/guides/attribution/sdk-integration/)** - LinkForty SDK setup --- **Questions?** - [View all guides](https://docs.linkforty.com/guides/links/creating-links/) - [GitHub Discussions](https://github.com/linkforty/core/discussions) - [Contact Support](mailto:inbox@linkforty.com) --- # Tutorials ## Try LinkForty with Fliq'd - See Your Dashboard in Action Source: https://docs.linkforty.com/tutorials/try-linkforty-with-fliq/ See LinkForty in action without writing any code. **Fliq'd** is a free secret messaging app that comes pre-integrated with the LinkForty SDK. Connect it to your workspace and watch real analytics appear in your dashboard within minutes. **Info: What is Fliq'd?** Fliq'd is a secret message app built by the LinkForty team. Users send encrypted messages that recipients reveal with a flick gesture. It's a fully functional app — available on the [App Store](https://apps.apple.com/app/fliq/id6760805503) and Google Play — that doubles as a live demo of LinkForty's SDK capabilities. --- #### What You'll See After connecting Fliq'd to your LinkForty workspace: | Action in Fliq'd | What appears in your dashboard | |---|---| | Share a message via link | A tracked short link on the **Shared Links** page | | Someone clicks your link | A **click event** with device, location, and browser data | | Someone installs the app from your link | An **install attribution** with confidence score | | Someone reveals a message | A `message_revealed` **custom event** | --- #### Step 1: Sign Up for LinkForty If you haven't already, create a free account at [app.linkforty.com](https://app.linkforty.com/register). When you register, a **Fliq'd Demo App** workspace is created automatically with: - All Fliq'd app settings pre-configured (bundle IDs, Universal Links, App Links) - A default link template ready for the SDK - An API key ready to copy --- #### Step 2: Copy Your API Key 1. Log in to [app.linkforty.com](https://app.linkforty.com) 2. Make sure you're in the **Fliq'd Demo App** workspace (check the sidebar) 3. Navigate to **Workspace Settings → API Keys** 4. Copy the auto-generated API key (starts with `dl_`) **Tip: Already have a workspace?** If you registered before the Fliq'd demo workspace was added, you can create a new workspace and click **Populate Fliq Demo** in the App Configuration section of Workspace Settings. Then create an API key manually under **Workspace Settings → API Keys**. --- #### Step 3: Download Fliq'd Install the app on your phone: - **iOS**: [App Store](https://apps.apple.com/app/fliq/id6760805503) - **Android**: Google Play (coming soon) Complete the onboarding (name, phone verification, push notifications). --- #### Step 4: Connect to LinkForty 1. In Fliq'd, tap the **gear icon** (top right) to open Settings 2. Scroll down to the **LinkForty SDK** section 3. Paste your API key into the **API Key** field 4. Tap **Connect** You should see a green "Connected" status indicator. Fliq'd is now sending analytics to your LinkForty dashboard. --- #### Step 5: Share a Message 1. Tap the **+** button to create a new secret 2. Enter your name and a message 3. Under **Delivery**, select **Share Link** 4. Tap **Share Secret** 5. Send the link to yourself (or a friend) via iMessage, WhatsApp, email, etc. --- #### Step 6: Check Your Dashboard Go back to [app.linkforty.com](https://app.linkforty.com) and you should see: - **Shared Links** — The link you just shared, with the sharer's ID and click count - **Dashboard** — Click and event counts updating - **Analytics** — Detailed click data (device, browser, country, referrer) - **Events** — Custom events like `message_created` and `message_revealed` **Tip: Not seeing data?** - Make sure the status shows **Connected** in Fliq'd settings - Make sure you used **Share Link** mode (Push to Phone doesn't create tracked links) - Click the shared link from a browser or messaging app to register a click - It may take a few seconds for events to appear in the dashboard --- #### How It Works When Fliq'd is connected to LinkForty: 1. **Message creation** → The app encrypts the message with AES-256-GCM on your device 2. **Link creation** → The LinkForty SDK creates a tracked short link with the encrypted data as deep link parameters 3. **Link sharing** → You share the tracked URL (e.g., `https://go.linkforty.com/slug/abc123#key`) 4. **Click tracking** → When the recipient taps the link, LinkForty records the click with device, location, and referrer data 5. **App opening** → The app receives the deep link, decrypts the message, and shows the reveal screen 6. **Event tracking** → Events like `message_revealed` are tracked via the SDK The decryption key is in the URL fragment (`#key`) and never reaches any server — not LinkForty's, not Fliq'd's. --- #### Standalone Mode Without an API key, Fliq'd works in **standalone mode**: - Messages are encrypted and encoded directly into the share URL - No data is sent to LinkForty - Push-to-phone messaging still works (uses Fliq'd's own server) - The app is fully functional — just without dashboard analytics --- #### Next Steps Now that you've seen LinkForty in action with a real app, you're ready to integrate the SDK into your own app: - [React Native SDK](https://docs.linkforty.com/sdks/react-native/) - [Expo SDK](https://docs.linkforty.com/sdks/expo/) - [iOS SDK](https://docs.linkforty.com/sdks/ios/) - [Android SDK](https://docs.linkforty.com/sdks/android/) - [Flutter SDK](https://docs.linkforty.com/sdks/flutter/) - [SDK Integration Guide](https://docs.linkforty.com/guides/attribution/sdk-integration/) --- # Implementation Guides ## Mobile Attribution Without IDFA - Complete Guide (2025) Source: https://docs.linkforty.com/tutorials/attribution-without-idfa/ In April 2021, Apple released iOS 14.5 with **App Tracking Transparency (ATT)**, requiring apps to ask users for permission before accessing their IDFA (Identifier for Advertisers). The result: **~70% of users decline permission**, effectively ending IDFA-based attribution for most iOS users. **This guide explains how to track mobile app installs and attribution in the post-ATT era without relying on IDFA.** --- #### What Changed with iOS 14.5? ##### Before iOS 14.5 (Pre-April 2021) **IDFA was accessible without user permission:** ```swift // Old way (pre-iOS 14.5) import AdSupport let idfa = ASIdentifierManager.shared().advertisingIdentifier.uuidString // Returns: "8C9B7F7E-4B3C-4F9D-9E8A-1234567890AB" ``` **Attribution platforms used IDFA:** 1. User clicks ad → IDFA recorded (`8C9B7F7E...`) 2. User installs app → IDFA read (`8C9B7F7E...`) 3. Match! → Install attributed to ad **Accuracy:** 95-100% (perfect matching) --- ##### After iOS 14.5 (April 2021 - Present) **ATT Framework requires permission prompt:** ```swift // New way (iOS 14.5+) import AppTrackingTransparency ATTrackingManager.requestTrackingAuthorization { status in switch status { case .authorized: // ~30% of users - IDFA accessible let idfa = ASIdentifierManager.shared().advertisingIdentifier.uuidString case .denied: // ~70% of users - IDFA returns zeros let idfa = "00000000-0000-0000-0000-000000000000" default: break } } ``` **The prompt users see:** ``` "YourApp would like to track you across apps and websites owned by other companies" [Ask App Not to Track] [Allow] ``` **Reality:** ~70% of users tap "Ask App Not to Track" --- ##### Impact on Mobile Attribution **Before iOS 14.5:** - IDFA-based attribution: 95-100% accuracy - No user permission needed - Standard across industry **After iOS 14.5:** - IDFA-based attribution: ~30% coverage (only users who grant permission) - 70% of users cannot be tracked with IDFA - Industry forced to adopt new methods **Result:** The mobile attribution industry had to completely reinvent itself. --- #### Attribution Methods Post-ATT ##### Method 1: Fingerprint Attribution (Privacy-Friendly) **How it works:** Match users based on device characteristics instead of IDFA. **Fingerprint components:** ```json { "ip": "192.168.1.100", "user_agent": "iPhone 15 Pro, iOS 17.2", "screen_width": 1179, "screen_height": 2556, "screen_density": 3, "language": "en-US", "timezone": "America/Los_Angeles", "carrier": "Verizon", "os_version": "17.2", "device_model": "iPhone15,2" } ``` **Matching process:** ``` 1. Click event: Record fingerprint {ip: "192.168.1.100", screen: "1179x2556", ...} 2. Install event: Record fingerprint {ip: "192.168.1.100", screen: "1179x2556", ...} 3. Compare fingerprints: ✓ IP matches ✓ Screen size matches ✓ Language matches ✓ Timezone matches ✓ OS version matches → Confidence: 85% 4. Attribution: Install attributed to click ``` **Accuracy:** - **70-80%** in ideal conditions - **60-70%** with network changes - **50-60%** on shared WiFi networks **Pros:** - **Works without user permission** (no ATT prompt) - **Privacy-friendly** (no persistent IDs) - **GDPR compliant** (fingerprints aren't personal data) - **Works for 100% of users** (not just 30%) **Cons:** - Lower accuracy than IDFA (70-80% vs 95-100%) - Can have false positives on shared IPs - Accuracy decreases if IP changes between click and install **Best for:** Privacy-conscious companies, general user acquisition **This is what LinkForty uses.** --- ##### Method 2: SKAdNetwork (Apple's Privacy-Safe Solution) **How it works:** Apple's official attribution API that provides aggregated, anonymized attribution data. **Flow:** ``` 1. User clicks ad → Ad network registers click with SKAdNetwork 2. User installs app → App notifies SKAdNetwork 3. 24-72 hours later → Apple sends postback to ad network 4. Ad network receives: "Campaign X drove an install" (anonymized) ``` **What you get:** ```json { "campaign_id": "1234", "conversion_value": "5", // You define what this means "did_win": true, "transaction_id": "abc123" } ``` **What you DON'T get:** - No user-level data (anonymized) - No immediate attribution (24-72 hour delay) - No device information - Limited conversion data (6 bits only) **Accuracy:** - **100% accurate** (Apple controls everything) - **But anonymized** (can't track individual users) **Pros:** - Apple-approved (no permission needed) - 100% accurate (Apple-controlled) - Privacy-preserving **Cons:** - 24-72 hour delay (not real-time) - Anonymized (can't track user journey) - Complex setup - Limited data (6-bit conversion value) - Requires ad network integration **Best for:** Paid advertising attribution (Facebook Ads, Google Ads) --- ##### Method 3: Probabilistic Attribution + Deterministic Fallback **How it works:** Combine fingerprinting (probabilistic) with deterministic methods when available. **Strategy:** ``` 1. Try deterministic methods first: - Universal Links (if user has app installed) - Server-to-server attribution (if user logs in) - IDFA (if user grants permission - ~30%) 2. Fall back to fingerprinting: - If deterministic methods unavailable - Fingerprint matching as backup ``` **Example flow:** ``` User clicks ad from Instagram IF user has app installed: → Universal Link opens app directly (100% attribution) ELSE IF user grants IDFA permission after install: → IDFA-based attribution (100% attribution) ELSE: → Fingerprint attribution (70-80% attribution) ``` **Best for:** Maximum accuracy across all scenarios **This is the industry standard approach in 2025.** --- #### Implementing Attribution Without IDFA ##### Option 1: Using LinkForty (Recommended) LinkForty uses **fingerprint attribution** (70-80% accuracy) with no user permission required. **1. Install SDK:** **React Native:** ```bash npm install @linkforty/mobile-sdk-react-native ``` **iOS (Swift Package Manager):** ```bash ### Add in Xcode: File → Add Package Dependencies ### URL: https://github.com/linkforty/mobile-sdk-ios ``` **Android (Gradle):** ```groovy implementation 'com.linkforty:sdk:1.3.0' ``` --- **2. Initialize:** ```javascript // React Native import LinkForty from '@linkforty/mobile-sdk-react-native'; await LinkForty.init({ baseUrl: 'https://api.linkforty.com', apiKey: 'your-api-key', debug: __DEV__, }); ``` ```swift // iOS import LinkForty let config = LinkFortyConfig( baseURL: "https://api.linkforty.com", apiKey: "your-api-key" ) LinkForty.shared.initialize(config: config) ``` ```kotlin // Android import com.linkforty.sdk.LinkForty import com.linkforty.sdk.LinkFortyConfig val config = LinkFortyConfig( baseURL = "https://api.linkforty.com", apiKey = "your-api-key" ) LinkForty.initialize(this, config) ``` --- **3. Check attribution on app launch:** ```javascript // React Native const installData = await LinkForty.getInstallData(); if (installData) { console.log('Install attributed to:', { campaign: installData.utmCampaign, source: installData.utmSource, medium: installData.utmMedium, }); // Route to content if (installData.deepLinkPath) { navigation.navigate(installData.deepLinkPath); } // Track attributed install analytics.track('attributed_install', { campaign: installData.utmCampaign, source: installData.utmSource, }); } ``` --- **4. Track events (optional):** ```javascript // Track purchase await LinkForty.trackEvent('purchase', { amount: 49.99, currency: 'USD', productId: 'premium-plan', }); // Track subscription await LinkForty.trackEvent('subscription_started', { plan: 'pro', price: 29.99, }); ``` --- **5. View attribution in dashboard:** ``` LinkForty Dashboard → Analytics See: - Click-to-install attribution - Campaign performance - Conversion rates - Geographic breakdown - Device breakdown All without requiring IDFA permission! ``` --- ##### Option 2: Build Your Own Fingerprinting **If you prefer to build in-house:** **1. Collect fingerprint at click time:** ```javascript // Click tracking server app.get('/:shortCode', async (req, res) => { const fingerprint = { ip: req.ip, userAgent: req.get('user-agent'), language: req.get('accept-language'), // Additional data from user-agent parsing: device: parseDevice(req.get('user-agent')), os: parseOS(req.get('user-agent')), browser: parseBrowser(req.get('user-agent')), }; // Store fingerprint with click event await db.clicks.create({ shortCode: req.params.shortCode, fingerprint: fingerprint, timestamp: new Date(), }); // Redirect to App Store/Google Play res.redirect(302, getAppStoreUrl(req)); }); ``` --- **2. Collect fingerprint at install time:** ```javascript // Mobile SDK (collect on first app open) const fingerprint = { ip: await getPublicIP(), // From server userAgent: DeviceInfo.getUserAgent(), screen: { width: Dimensions.get('window').width * PixelRatio.get(), height: Dimensions.get('window').height * PixelRatio.get(), }, language: DeviceInfo.getDeviceLocale(), timezone: RNLocalize.getTimeZone(), carrier: await DeviceInfo.getCarrier(), os: { name: Platform.OS, version: DeviceInfo.getSystemVersion(), }, device: { model: DeviceInfo.getModel(), brand: DeviceInfo.getBrand(), }, }; // Send to server for matching const response = await fetch('https://yourapi.com/attribution/match', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fingerprint }), }); const attribution = await response.json(); ``` --- **3. Match fingerprints:** ```javascript // Server-side matching algorithm async function matchFingerprint(installFingerprint) { // Find recent clicks (within attribution window) const recentClicks = await db.clicks.find({ timestamp: { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // 7 days }, }); // Score each click based on similarity const scores = recentClicks.map(click => ({ click, score: calculateSimilarity(click.fingerprint, installFingerprint), })); // Sort by score (highest first) scores.sort((a, b) => b.score - a.score); // Return best match if score > threshold const bestMatch = scores[0]; if (bestMatch && bestMatch.score > 0.7) { // 70% threshold return { matched: true, attribution: bestMatch.click, confidence: bestMatch.score, }; } return { matched: false }; } function calculateSimilarity(fp1, fp2) { let score = 0; let maxScore = 0; // IP address (weight: 30%) maxScore += 30; if (fp1.ip === fp2.ip) { score += 30; } else if (isSameSubnet(fp1.ip, fp2.ip)) { score += 15; // Half credit for same subnet } // Screen resolution (weight: 20%) maxScore += 20; if (fp1.screen?.width === fp2.screen?.width && fp1.screen?.height === fp2.screen?.height) { score += 20; } // Language (weight: 10%) maxScore += 10; if (fp1.language === fp2.language) { score += 10; } // Timezone (weight: 10%) maxScore += 10; if (fp1.timezone === fp2.timezone) { score += 10; } // OS version (weight: 10%) maxScore += 10; if (fp1.os?.version === fp2.os?.version) { score += 10; } // Device model (weight: 20%) maxScore += 20; if (fp1.device?.model === fp2.device?.model) { score += 20; } return score / maxScore; // Normalized 0-1 } ``` --- **Time to build:** 2-4 weeks **Challenges:** - IP address handling (proxies, VPNs, carrier NAT) - False positive prevention - Attribution window management - Scale (matching millions of clicks/installs) **Recommendation:** Use LinkForty instead (saves 2-4 weeks, battle-tested) --- #### Accuracy Expectations ##### Fingerprinting Accuracy by Scenario | Scenario | Accuracy | Explanation | |----------|----------|-------------| | **Same WiFi, <1 hour** | 90-95% | Ideal conditions | | **Same cellular, <24h** | 80-85% | Cellular IPs more unique | | **Different network** | 60-70% | IP changed, other factors match | | **Shared WiFi (office)** | 50-60% | Multiple devices, same IP | | **Long time (3-7 days)** | 65-75% | IP may have changed | | **VPN user** | 40-50% | VPN changes IP, other factors still match | **LinkForty achieves:** 70-80% average accuracy (industry standard) --- ##### Comparing to IDFA | Method | Accuracy | Coverage | Privacy | Permission Required | |--------|----------|----------|---------|---------------------| | **IDFA (pre-iOS 14.5)** | 95-100% | 100% | Low | No | | **IDFA (post-iOS 14.5)** | 95-100% | ~30% | Low | Yes (ATT) | | **Fingerprinting** | 70-80% | 100% | High | No | | **SKAdNetwork** | 100%* | 100% | High | No | *SKAdNetwork is 100% accurate but anonymized (no user-level data) --- #### Best Practices for Post-ATT Attribution ##### 1. Don't Show ATT Prompt (Unless You Have Good Reason) **Bad:** ```swift // App launch ATTrackingManager.requestTrackingAuthorization { status in // 70% of users will decline } ``` **Why bad:** - 70% of users decline anyway - Negative first impression - Reduces app quality perception - Alternatives work without permission **Good:** ```swift // Don't show ATT prompt at all // Use fingerprinting instead (70-80% accuracy for everyone) ``` **Exception:** Only show ATT if you have a compelling reason: - Personalized ads (explain the benefit) - Cross-app experiences - Genuine value to user --- ##### 2. Use Fingerprinting as Primary Method **Strategy:** ``` Primary: Fingerprinting (works for 100% of users, 70-80% accuracy) Fallback: IDFA (if user grants permission - bonus data for ~30%) ``` **Implementation:** ```javascript // Check for fingerprint attribution first const installData = await LinkForty.getInstallData(); if (installData) { // Got attribution via fingerprinting trackAttributedInstall(installData); } // Optionally check IDFA (if you have a reason to ask) // Most apps should skip this ``` --- ##### 3. Shorten Attribution Windows **Old approach (IDFA era):** - 30-90 day attribution windows common - IDFA matching was 100% accurate even after weeks **New approach (fingerprinting):** - 7-day attribution window recommended - Accuracy decreases over time (IP changes, settings changes) **Optimal windows:** - **7 days:** Standard (recommended) - **24-48 hours:** High-intent campaigns (flash sales) - **14 days:** Consideration products (max) --- ##### 4. Track Campaign Performance, Not Individual Users **Old mindset (IDFA):** - Track every user's journey - Detailed user-level attribution - Cross-app tracking **New mindset (privacy-first):** - Track campaign performance (aggregate) - Understand which channels drive installs - Respect user privacy **Example:** ```javascript // Good: Campaign-level insights Dashboard shows: - Instagram campaign: 1,000 installs, $5 CPI - Facebook campaign: 500 installs, $8 CPI → Optimize budget allocation // Don't need: Individual user tracking // "User ABC123 clicked ad at 2pm, installed at 3pm, made purchase at 5pm" // This is unnecessary and privacy-invasive ``` --- ##### 5. Combine with SKAdNetwork for Paid Ads **For paid advertising (Facebook, Google, TikTok):** ``` Use BOTH: 1. SKAdNetwork (for ad platform attribution) 2. Fingerprinting (for your own analytics) ``` **Why both:** - SKAdNetwork gives ad platforms attribution data (required) - Fingerprinting gives you real-time, detailed analytics - Together: Complete picture --- #### Privacy Compliance ##### GDPR Compliance **Is fingerprinting GDPR-compliant?** **Yes**, when implemented correctly: **Why it's compliant:** 1. **Not personal data:** Device characteristics ≠ personally identifiable information 2. **Legitimate interest:** Attribution is necessary business function 3. **Temporary:** Data used only during attribution window (7 days) 4. **No cross-app tracking:** Only tracks within your app's attribution **Legal basis:** Legitimate interest (Article 6(1)(f) GDPR) --- ##### User Rights Under GDPR **You must support:** 1. **Right to access:** User can request attribution data 2. **Right to deletion:** User can delete attribution records 3. **Right to object:** User can opt-out of attribution **Implementation:** ```javascript // Wipe all locally stored SDK data (install ID, attribution data, first-launch flag). // The next launch is treated as a fresh install, so the SDK stops associating // activity with the prior attribution. await LinkForty.clearData(); ``` To honor a deletion request for data already stored server-side, handle it through your account/data-request process — the SDK's `clearData()` only clears data on the device. --- ##### Privacy Policy Requirements **Your privacy policy should disclose:** ```markdown #### Mobile Attribution We use device fingerprinting to attribute app installs to marketing campaigns. **Data collected:** - IP address (anonymized after 7 days) - Device characteristics (screen size, language, timezone) - Click timestamp **Purpose:** Understanding which marketing campaigns drive app installs **Retention:** 7 days (deleted after attribution window) **Legal basis:** Legitimate interest (GDPR Article 6(1)(f)) **Your rights:** You can request deletion of attribution data at any time ``` --- #### Summary iOS 14.5 changed mobile attribution forever. IDFA-based tracking went from 100% coverage to ~30% overnight. The industry adapted with **fingerprint attribution** as the new standard. **Key Takeaways:** - **Fingerprinting is the new standard** (70-80% accuracy, no permission needed) - **Don't ask for ATT permission** unless you have a compelling reason - **Focus on campaign performance**, not individual user tracking - **Use LinkForty** for privacy-first, cost-effective attribution - **Combine with SKAdNetwork** for paid ad attribution **The future is privacy-first attribution.** --- #### Get Started with LinkForty **Cloud (5-minute setup):** 1. Sign up at [linkforty.com](https://linkforty.com) 2. Create your first link 3. Integrate SDK 4. Get 70-80% attribution without asking for permission **Self-Hosted (Free):** 1. `npm install @linkforty/core` 2. Deploy to your infrastructure 3. Full data ownership [Get Started →](https://docs.linkforty.com/getting-started/quick-start/) [View SDK Integration Guide →](https://docs.linkforty.com/guides/attribution/sdk-integration/) --- #### Next Steps - **[What is Mobile Attribution?](https://docs.linkforty.com/learn/what-is-mobile-attribution/)** - Attribution fundamentals - **[Deferred Deep Linking](https://docs.linkforty.com/learn/deferred-deep-linking-explained/)** - How fingerprinting works --- **Questions?** - [View all guides](https://docs.linkforty.com/guides/links/creating-links/) - [GitHub Discussions](https://github.com/linkforty/core/discussions) - [Contact Support](mailto:inbox@linkforty.com) --- # Reference ## Mobile Attribution & Deep Linking Glossary Source: https://docs.linkforty.com/reference/glossary/ Comprehensive definitions of mobile attribution and deep linking terms. Use this as a reference when learning about mobile app marketing and attribution. --- #### A ##### App Links (Android) Android's implementation of HTTPS-based deep linking. Allows regular HTTPS URLs (like `https://yourapp.com/path`) to open an Android app directly if installed, or fall back to the website if not. Similar to iOS Universal Links. **Requirements:** - Owned domain with HTTPS - `assetlinks.json` verification file hosted at `/.well-known/` - SHA-256 fingerprint of app signing key - Intent filters in AndroidManifest.xml **Example:** ``` https://go.yourapp.com/products/123 → Opens Android app (if installed) → Opens website (if app not installed) ``` [Learn more →](https://docs.linkforty.com/learn/universal-links-vs-app-links/) --- ##### App Store Optimization (ASO) The process of improving an app's visibility in app stores (Apple App Store, Google Play Store) to increase organic installs. **Key ASO factors:** - App title and description - Keywords - Screenshots and preview videos - Ratings and reviews - Download velocity --- ##### App Tracking Transparency (ATT) Apple framework introduced in iOS 14.5 (April 2021) requiring apps to request user permission before accessing their IDFA (Identifier for Advertisers). **The ATT prompt:** ``` "YourApp would like to track you across apps and websites owned by other companies" [Ask App Not to Track] [Allow] ``` **Impact:** ~70% of users decline permission, ending IDFA-based attribution for most users. [Learn more →](https://docs.linkforty.com/tutorials/attribution-without-idfa/) --- ##### Associated Domains iOS capability that allows an app to be associated with specific domains for Universal Links. Configured in Xcode under "Signing & Capabilities". **Example:** ``` applinks:go.yourapp.com applinks:yourapp.com ``` --- ##### Attribution The process of determining which marketing campaign, channel, or touchpoint caused a user to install an app or take an action within it. **Example:** ``` User clicks Instagram ad → Installs app Attribution: Install attributed to "Instagram campaign" ``` **Purpose:** Understand marketing ROI, optimize ad spend, measure campaign effectiveness. [Learn more →](https://docs.linkforty.com/learn/what-is-mobile-attribution/) --- ##### Attribution Model The methodology used to assign credit for conversions (installs, purchases) to marketing touchpoints. **Common models:** - **Last-click:** 100% credit to final touchpoint - **First-click:** 100% credit to initial touchpoint - **Multi-touch:** Credit distributed across multiple touchpoints - **Fingerprint:** Device characteristic matching [Learn more →](https://docs.linkforty.com/learn/what-is-mobile-attribution/) --- ##### Attribution Window The time period during which a click can be credited for an install or conversion. **Example with 7-day window:** ``` Day 1: User clicks ad Day 2-7: User installs → Click gets credit Day 8+: User installs → Labeled "organic" (window expired) ``` **Common windows:** - 24 hours (flash sales) - 7 days (industry standard) - 14-30 days (consideration products) --- #### B ##### Bundle ID (iOS) Unique identifier for an iOS app in reverse domain format. **Example:** `com.yourcompany.yourapp` **Used for:** - App Store registration - Universal Links (AASA file) - Push notifications - App signing --- #### C ##### Click-Through Rate (CTR) Percentage of people who click a link after seeing it. **Formula:** `(Clicks / Impressions) × 100` **Example:** ``` 1,000 people see ad 100 people click CTR = (100 / 1,000) × 100 = 10% ``` --- ##### Contextual Deep Link A deep link that carries additional context or parameters beyond just routing. **Example:** ``` https://go.yourapp.com/products/123? utm_source=instagram& discount=SUMMER20& referrer=user456 ``` **The app receives:** - Destination: Product 123 - Traffic source: Instagram - Discount code: SUMMER20 - Referrer: user456 --- ##### Conversion Rate Percentage of users who complete a desired action. **Common conversions:** - Click → Install - Install → Purchase - Install → Subscription **Formula:** `(Conversions / Total Users) × 100` --- ##### Cost Per Install (CPI) The average cost to acquire one app install through paid advertising. **Formula:** `Total Ad Spend / Number of Installs` **Example:** ``` $10,000 ad spend 1,000 installs CPI = $10,000 / 1,000 = $10 ``` **Industry benchmarks:** - Gaming: $1-$5 - E-commerce: $5-$15 - Finance: $20-$80 --- ##### Custom URL Scheme App-specific URL format used for deep linking. Older method predating Universal Links and App Links. **Format:** `myapp://path/to/content` **Problems:** - Only works if app installed - Scheme conflicts possible - Not shareable as web links - Security warnings in browsers **Modern alternative:** Universal Links / App Links --- #### D ##### Deep Link A link that opens a specific page or content within a mobile app, rather than just opening the app's home screen. **Example:** ``` Standard: myapp:// → Opens app home screen Deep link: myapp://products/123 → Opens Product 123 page ``` **Modern approach:** Use HTTPS URLs (Universal Links / App Links) instead of custom schemes. [Learn more →](https://docs.linkforty.com/learn/what-is-deep-linking/) --- ##### Deferred Deep Link A deep link that routes users to specific content **after** they install the app, remembering the context of the original link clicked before installation. **Flow:** ``` 1. User (no app) clicks link to Product 123 2. Redirected to App Store 3. User installs app 4. User opens app 5. App automatically routes to Product 123 ``` **How it works:** Fingerprint matching (device characteristics) **Accuracy:** 70-80% with fingerprinting [Learn more →](https://docs.linkforty.com/learn/deferred-deep-linking-explained/) --- ##### Device Fingerprint A combination of device characteristics used to uniquely identify a device without using persistent IDs (like IDFA). **Fingerprint components:** - IP address - Screen resolution - User agent (device model, OS version) - Language settings - Timezone - Carrier information **Used for:** Attribution matching in privacy-friendly way (no IDFA required) **Accuracy:** 70-80% for attribution --- ##### Dynamic Link General term for links that adapt behavior based on context (device type, app installed status, location, etc.). Often used synonymously with "deep link." **Note:** Also the former name of Firebase's deep linking product (Firebase Dynamic Links, deprecated August 2025). --- #### F ##### Fingerprinting See **Device Fingerprint** --- ##### Firebase Dynamic Links Google's HTTPS-based deep linking service. **Deprecated August 25, 2025, shutting down August 25, 2025.** **Replacement:** Use LinkForty, Universal Links, App Links, or other attribution platforms. --- #### G ##### GAID (Google Advertising ID) Android's equivalent of IDFA. Unique identifier for advertising purposes on Android devices. **Access:** Available to apps, but Google plans to restrict access (similar to Apple's ATT). **Format:** 128-bit UUID (e.g., `38400000-8cf0-11bd-b23e-10b96e40000d`) --- #### I ##### IDFA (Identifier for Advertisers) Apple's unique identifier for advertising purposes on iOS devices. Post-iOS 14.5 (April 2021), requires user permission via ATT prompt. **Reality:** ~70% of users decline permission **Format:** 128-bit UUID **Alternative:** Fingerprinting (no permission required, 70-80% accuracy) [Learn more →](https://docs.linkforty.com/tutorials/attribution-without-idfa/) --- ##### Impression A single instance of an ad or link being displayed to a user. **Example:** ``` 1,000 users see your ad → 1,000 impressions ``` --- ##### Install Attribution The process of determining which marketing source caused an app to be installed. See **Attribution** --- #### L ##### Last-Click Attribution Attribution model where 100% of credit goes to the last touchpoint before conversion. **Example:** ``` User journey: 1. Sees Facebook ad 2. Sees email campaign 3. Clicks Instagram ad → Installs Attribution: Instagram gets 100% credit (last click) ``` --- ##### Lifetime Value (LTV) The total revenue generated by a user over their entire relationship with your app. **Formula:** `Average Purchase Value × Purchase Frequency × Customer Lifespan` **Example:** ``` User spends $10/month User stays for 12 months LTV = $10 × 12 = $120 ``` **Used for:** Determining acceptable customer acquisition costs --- ##### Link Shortener A service that creates short, memorable URLs that redirect to longer original URLs. **Example:** ``` Original: https://yourapp.com/products/winter-jacket-2024?utm_source=instagram Shortened: https://go.yourapp.com/abc123 ``` **Benefits:** Easier to share, track clicks, update destinations --- #### M ##### Mobile Measurement Partner (MMP) A third-party platform that provides mobile attribution and analytics services. Also called "attribution platform." **Examples:** - Enterprise: AppsFlyer, Branch, Adjust, Singular - Open Source: LinkForty **What MMPs provide:** - Attribution tracking - Deep linking - Analytics - Fraud detection (enterprise MMPs) - Ad network integrations [Learn more →](https://docs.linkforty.com/learn/what-is-mobile-attribution/) --- ##### Multi-Touch Attribution Attribution model that distributes credit across multiple touchpoints in the user journey. **Example:** ``` User journey: 1. Sees Facebook ad (30% credit) 2. Sees email (30% credit) 3. Clicks Instagram ad (40% credit) All three touchpoints receive partial credit ``` --- #### O ##### Organic Install An app install that cannot be attributed to a specific marketing campaign. User found the app through: - App Store search - Word of mouth - Direct URL entry - Click outside attribution window **Opposite:** Attributed install --- #### P ##### Package Name (Android) Unique identifier for an Android app in reverse domain format. **Example:** `com.yourcompany.yourapp` **Used for:** - Google Play registration - App Links (assetlinks.json) - Push notifications - App signing --- #### Q ##### QR Code Quick Response code: 2D barcode that encodes URL or data, scannable by smartphone cameras. **Mobile attribution use:** ``` QR code on product packaging → Encodes: https://go.yourapp.com/product/xyz → User scans → Installs app → Routes to product → Offline-to-online attribution ``` [Learn more →](https://docs.linkforty.com/guides/links/qr-codes/) --- #### R ##### Referral Program Marketing strategy where existing users invite new users, often with incentives for both parties. **Example:** ``` User A shares: https://go.yourapp.com/invite/userA User B clicks, installs Both receive bonus: User A: $10 credit, User B: $10 credit ``` **Requires:** Deferred deep linking for attribution --- ##### Retargeting Marketing to users who previously interacted with your app or website. **Example:** ``` User visited product page but didn't buy → Show ad for that product → Higher conversion rate (user already interested) ``` --- ##### Return on Ad Spend (ROAS) Revenue generated per dollar spent on advertising. **Formula:** `Revenue from Ads / Ad Spend` **Example:** ``` $10,000 ad spend $30,000 revenue generated ROAS = $30,000 / $10,000 = 3x (or 300%) ``` **Good ROAS:** - E-commerce: 3-5x - Subscription: 2-4x - Gaming: 1.5-3x --- #### S ##### Short Link Condensed URL that redirects to a longer original URL. See **Link Shortener** --- ##### SKAdNetwork Apple's privacy-preserving attribution API for iOS. Provides aggregated, anonymized attribution data without requiring IDFA. **How it works:** ``` 1. User clicks ad → SKAdNetwork registers 2. User installs app → SKAdNetwork notified 3. 24-72 hours later → Apple sends postback to ad network 4. Ad network receives: "Campaign X drove an install" (anonymized) ``` **Pros:** - No user permission required - Apple-approved - 100% accurate (Apple-controlled) **Cons:** - 24-72 hour delay - Anonymized (no user-level data) - Complex setup - Limited conversion data (6 bits) [Learn more →](https://docs.linkforty.com/tutorials/attribution-without-idfa/) --- #### T ##### Tracking Link A URL that tracks clicks, installs, and conversions. Often includes parameters for campaign attribution. **Example:** ``` https://go.yourapp.com/summer-sale? utm_source=instagram& utm_medium=social& utm_campaign=summer-2024 ``` --- #### U ##### Universal Links (iOS) iOS implementation of HTTPS-based deep linking. Allows regular HTTPS URLs to open an iOS app directly if installed, or fall back to the website if not. **Requirements:** - Owned domain with HTTPS - `apple-app-site-association` (AASA) file hosted at `/.well-known/` - Associated Domains capability in Xcode - AppDelegate implementation **Example:** ``` https://go.yourapp.com/products/123 → Opens iOS app (if installed) → Opens website (if app not installed) ``` [Learn more →](https://docs.linkforty.com/learn/universal-links-vs-app-links/) --- ##### User Agent HTTP header that identifies the device, browser, and OS making a request. **Example:** ``` Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1 ``` **Used for:** - Device detection (iOS vs Android) - Device fingerprinting - Analytics --- ##### User Acquisition (UA) The process of gaining new app users through marketing efforts. **UA channels:** - Paid advertising (Facebook, Google, TikTok) - Organic (App Store search, referrals) - Influencer marketing - Content marketing **Goal:** Acquire users cost-effectively (low CPI, high LTV) --- ##### UTM Parameters URL parameters used to track campaign performance in analytics platforms. **Standard UTM parameters:** - `utm_source`: Traffic source (e.g., instagram, facebook) - `utm_medium`: Marketing medium (e.g., social, email, cpc) - `utm_campaign`: Campaign name (e.g., summer-sale-2024) - `utm_content`: Ad variation (e.g., banner-a, video-b) - `utm_term`: Keyword (for paid search) **Example:** ``` https://yourapp.com/product? utm_source=instagram& utm_medium=social& utm_campaign=summer-sale& utm_content=carousel-ad ``` --- #### V ##### Viral Loop Growth mechanism where existing users invite new users, who then invite more users, creating exponential growth. **Example:** ``` User A invites 3 friends (B, C, D) Each friend invites 3 more 3 → 9 → 27 → 81 → exponential growth ``` **Requires:** Easy sharing mechanism, incentives, deferred deep linking --- #### W ##### Webhook HTTP callback that sends real-time event notifications to your server. **LinkForty webhooks:** ```javascript POST https://yourapp.com/webhooks/linkforty Body: { "event": "app.installed", "data": { "linkId": "abc123", "campaign": "summer-sale", "timestamp": "2024-01-15T10:30:00Z" } } ``` **Use cases:** - Send events to analytics platforms - Award referral credits - Trigger email sequences - Update CRM records --- #### Summary This glossary covers 50+ terms essential to understanding mobile attribution and deep linking. Bookmark this page as a reference when working with mobile app marketing, attribution, and deep linking. **Key concepts to understand:** - **Deep linking:** HTTPS URLs that open apps (Universal Links, App Links) - **Attribution:** Determining which campaigns drive installs - **Fingerprinting:** Privacy-friendly attribution method (70-80% accuracy) - **IDFA/ATT:** Old attribution method, now requires permission (~70% decline) - **MMP:** Attribution platforms like LinkForty, AppsFlyer, Branch **Ready to implement?** [Get Started with LinkForty →](https://docs.linkforty.com/getting-started/quick-start/) --- #### Related Resources - **[What is Deep Linking?](https://docs.linkforty.com/learn/what-is-deep-linking/)** - Deep linking fundamentals - **[What is Mobile Attribution?](https://docs.linkforty.com/learn/what-is-mobile-attribution/)** - Attribution basics - **[Deferred Deep Linking Explained](https://docs.linkforty.com/learn/deferred-deep-linking-explained/)** - How install attribution works - **[Attribution Without IDFA](https://docs.linkforty.com/tutorials/attribution-without-idfa/)** - Privacy-first attribution guide - **[Universal Links vs App Links](https://docs.linkforty.com/learn/universal-links-vs-app-links/)** - Platform comparison --- **Questions?** - [View all guides](https://docs.linkforty.com/guides/links/creating-links/) - [GitHub Discussions](https://github.com/linkforty/core/discussions) - [Contact Support](mailto:inbox@linkforty.com) --- ## Environment Variables Source: https://docs.linkforty.com/reference/environment-variables/ Configuration reference for [self-hosting LinkForty Core](https://docs.linkforty.com/getting-started/self-hosting/). Set these in your `.env` file (or your platform's environment configuration). Only `DATABASE_URL` is strictly required; everything else has a sensible default or is optional. #### Database | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `DATABASE_URL` | **Yes** | — | PostgreSQL connection string: `postgresql://user:password@host:5432/dbname`. | | `POSTGRES_DB` | No | — | Database name (Docker Compose convenience; used to compose `DATABASE_URL`). | | `POSTGRES_USER` | No | — | Database user (Docker Compose). | | `POSTGRES_PASSWORD` | No | — | Database password (Docker Compose). | | `POSTGRES_PORT` | No | `5432` | Database port (Docker Compose). | #### Caching (optional) | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `REDIS_URL` | No | — | Redis connection string for link caching (`redis://host:6379`). **Omit to disable** — Core falls back to direct database queries. | | `REDIS_PORT` | No | `6379` | Redis port (Docker Compose). | #### Server | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `NODE_ENV` | No | `development` | Set to `production` in production (enables SSL behavior and hides internal error details). | | `PORT` | No | `3000` | Port the server listens on. | | `HOST` | No | `0.0.0.0` | Bind address. | | `CORS_ORIGIN` | No | `*` | Allowed CORS origin(s). Set to your frontend domain in production. | | `SERVICE_DOMAIN` | No | — | Custom domain used when generating short links. | | `JWT_SECRET` | No | — | Secret for signing JWTs (if you use Core's authentication). Generate with `openssl rand -hex 32`. | #### Attribution behind a CDN or proxy {#trusted-client-ip-header} | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `TRUSTED_CLIENT_IP_HEADER` | No | unset | Name of the header carrying the authoritative client IP, e.g. `cf-connecting-ip`. When unset, Core uses the connection IP (`request.ip`). | When LinkForty runs behind a CDN or reverse proxy that terminates the connection (Cloudflare, Fastly, a load balancer), the connection IP Core sees is the **edge or NAT hop**, not the real visitor. Because fingerprint attribution keys on the client IP, this degrades match accuracy — clicks from many visitors collapse onto one proxy IP. If your proxy injects an authoritative client-IP header, set `TRUSTED_CLIENT_IP_HEADER` to that header name so Core reads the real client IP instead. Common values: ```bash ### Cloudflare TRUSTED_CLIENT_IP_HEADER=cf-connecting-ip ### Akamai / some CDNs TRUSTED_CLIENT_IP_HEADER=true-client-ip ``` Comma-separated header values are handled automatically (Core takes the first entry), and IPv6-mapped IPv4 addresses are normalized. **Danger: Only enable behind a trusted proxy** Set this **only** when your origin is reachable *exclusively* through the proxy that sets the header. If clients can reach your server directly, they could spoof the header and forge their IP. When in doubt, leave it unset. **Info: LinkForty Cloud** On LinkForty Cloud this is already configured (`cf-connecting-ip`). You only need this variable for self-hosted deployments behind a CDN. #### Mobile app links These power the `.well-known` files Core serves for iOS Universal Links and Android App Links. See [iOS Universal Links](https://docs.linkforty.com/guides/attribution/sdk-integration/#ios-universal-links) and [Android App Links](https://docs.linkforty.com/guides/attribution/sdk-integration/#android-app-links) for setup. | Variable | Required | Default | Description | |----------|----------|---------|-------------| | `IOS_TEAM_ID` | No | — | Apple Developer Team ID. Required to serve `apple-app-site-association`. | | `IOS_BUNDLE_ID` | No | — | Your iOS app bundle identifier (e.g. `com.yourcompany.yourapp`). | | `ANDROID_PACKAGE_NAME` | No | — | Your Android package name. Required to serve `assetlinks.json`. | | `ANDROID_SHA256_FINGERPRINTS` | No | — | Comma-separated SHA-256 signing-certificate fingerprints. | When the iOS pair is unset, `GET /.well-known/apple-app-site-association` returns a 404 with a configuration message. The same applies to the Android pair and `assetlinks.json`. #### Example `.env` ```bash ### Database DATABASE_URL=postgresql://linkforty:your-strong-password@localhost:5432/linkforty ### Caching (optional) REDIS_URL=redis://localhost:6379 ### Server NODE_ENV=production PORT=3000 HOST=0.0.0.0 CORS_ORIGIN=https://yourdomain.com SERVICE_DOMAIN=yourdomain.com ### Attribution behind Cloudflare TRUSTED_CLIENT_IP_HEADER=cf-connecting-ip ### iOS Universal Links IOS_TEAM_ID=ABC123XYZ IOS_BUNDLE_ID=com.yourcompany.yourapp ### Android App Links ANDROID_PACKAGE_NAME=com.yourcompany.yourapp ANDROID_SHA256_FINGERPRINTS=AA:BB:CC:DD:... ``` #### Related - [Self-Hosting Guide](https://docs.linkforty.com/getting-started/self-hosting/) — full deployment walkthrough - [SDK Integration](https://docs.linkforty.com/guides/attribution/sdk-integration/) — Universal Links / App Links setup - [Attribution Windows](https://docs.linkforty.com/guides/attribution/attribution-windows/) — tune match windows --- ## For AI Tools (llms.txt) Source: https://docs.linkforty.com/reference/llms-txt/ LinkForty provides machine-readable documentation files designed for AI coding assistants. If you're using tools like **Claude**, **ChatGPT**, **Cursor**, **Windsurf**, **GitHub Copilot**, or any LLM-powered coding tool, these files give your AI everything it needs to integrate LinkForty into your project. **Tip: Want live tool access instead of static context?** The [LinkForty MCP server](https://docs.linkforty.com/guides/advanced/mcp-server/) connects your AI assistant directly to your workspace with 20 tools for managing links, querying analytics, and more — no copy-pasting needed. The files below are for giving AI tools **read-only reference material**; the MCP server gives them **live, authenticated access** to your data. #### Available Files Both documentation files are generated from these docs on every build, in the same order as the sidebar, so they always match what you can read here. They are never edited by hand. ##### llms.txt — Quick Reference A table of contents with a link to every documentation page and a one-line description of each. **URL:** [https://docs.linkforty.com/llms.txt](pathname:///llms.txt) **Best for:** AI tools that can follow links, or when you want to point your assistant to a specific topic. **Size:** ~15 KB ##### llms-full.txt — Complete Documentation The full text of every documentation page in one file: getting started, every guide, the API reference, all SDK guides, the SDK specification, tutorials, and reference pages. **URL:** [https://docs.linkforty.com/llms-full.txt](pathname:///llms-full.txt) **Best for:** Indexing into a retrieval system, adding to a project context file, or giving an agent that can search a large file everything at once. It is large; for a chat paste, pick the pages you need from `llms.txt` instead. **Size:** ~750 KB ##### linkforty.com/llms.txt — Product Overview The product site has its own file at [https://linkforty.com/llms.txt](https://linkforty.com/llms.txt). It answers product questions rather than integration ones: what LinkForty is, who it is for, pricing, the feature pages, free tools, and articles, with pointers back to the files above. A companion `llms-full.txt` on the same domain carries the full text of the feature pages and articles. #### How to Use These Files ##### With Claude Code or Cursor Add the full reference to your project context so the AI always has it available: ```bash ### Download to your project curl -o LINKFORTY.md https://docs.linkforty.com/llms-full.txt ``` Then reference it in your `.cursorrules`, `CLAUDE.md`, or equivalent project instructions file: ``` When integrating LinkForty, refer to LINKFORTY.md for the complete API and SDK reference. ``` ##### With ChatGPT, Claude Chat, or Similar Copy the contents of [llms-full.txt](pathname:///llms-full.txt) and paste it into your conversation, then ask the AI to integrate LinkForty. For example: > Here is the LinkForty integration reference: [paste llms-full.txt] > > I'm building a React Native app with Expo Router. Add LinkForty deep linking so that shared product links open the product screen. ##### With GitHub Copilot Add the file to your repository so Copilot indexes it: ```bash curl -o docs/LINKFORTY_REFERENCE.md https://docs.linkforty.com/llms-full.txt ``` ##### In CI/CD or Automation Fetch the latest reference programmatically: ```bash curl -s https://docs.linkforty.com/llms-full.txt ``` #### What's Inside llms-full.txt The file mirrors the documentation sidebar, section by section: | Section | What It Contains | |---------|-----------------| | **Getting Started** | Why LinkForty, introduction, quick start, self-hosting, Cloud vs self-hosted, production checklist | | **Guides** | Links (custom domains, templates, UTM, QR codes, shared links, social previews), mobile attribution, analytics (campaigns, events, app and web analytics, export), team, and advanced features (webhooks, integrations, email link tracking, MCP server, link safety) | | **API Reference** | Authentication, rate limits, errors, and every documented endpoint with request and response examples | | **SDKs** | React Native, Expo, iOS, Android, Flutter, the web pixel, and the SDK specification | | **Learn and Tutorials** | Deep linking and attribution fundamentals, implementation guides | | **Reference** | Glossary, environment variables, this page | Each page starts with its source URL, so an assistant can cite where an answer came from. #### About the llms.txt Standard `llms.txt` is an [emerging convention](https://llmstxt.org/) for providing LLM-friendly documentation, similar to how `robots.txt` guides web crawlers and `sitemap.xml` guides search engines. It places structured, AI-optimized content at a well-known URL so that AI tools and agents can discover and use it. --- # Migrations ## Migrate from Self-Hosted Core to Cloud Source: https://docs.linkforty.com/migrations/core-to-cloud/ LinkForty Cloud runs on the same open-source engine as self-hosted [Core](https://docs.linkforty.com/getting-started/self-hosting/) — Cloud is Core plus multi-tenant workspaces, billing, custom domains, and a managed dashboard. Because the two share the same link model, moving to Cloud is mostly a matter of **copying your link definitions into a Cloud workspace, repointing your domain, and updating your SDK configuration**. **Info: Cloud feature** This guide moves you *to* LinkForty Cloud. If you'd rather keep running your own infrastructure, see [Cloud vs Self-Hosted](https://docs.linkforty.com/getting-started/cloud-vs-self-hosted/). It's the same engine either way, so you can always take your data back out and self-host again later. #### What migrates — and what doesn't Be clear-eyed about scope before you start: | Data | Migrates? | Notes | |---|---|---| | Link definitions (short code, destinations, UTM) | ✅ Yes | Via the CSV importer or the links API | | Short codes | ✅ Usually | Preserved unless the code is already taken (short codes are globally unique) | | Deep link params, targeting rules, custom schemes, attribution windows | ⚠️ API only | The CSV importer carries URLs + UTM only; recreate via the API to keep these | | Historical analytics (clicks, installs, conversions) | ❌ No | Analytics start fresh in Cloud — there is no import for historical events | | Device fingerprints / in-flight install attribution | ❌ No | Cannot be moved | The practical approach: **migrate your link definitions, accept a clean analytics slate, and run Core and Cloud in parallel during cutover** so no live links break. #### Step 1 — Create your Cloud workspace 1. Sign up at [linkforty.com](https://linkforty.com) and create your organization (workspace). See [Organizations](https://docs.linkforty.com/guides/team/organizations/). 2. In the dashboard, create an **API key** under **Settings → API Keys**. The full key is shown **once**, on creation — copy it now; you'll need it for the SDK and (optionally) the links API. #### Step 2 — Export your links from Core Core doesn't ship a CSV export, so pull your links out and reshape them into the importer's format. Core's `links` table columns already match the importer's headers, so this is straightforward. Two options: **Option A — Core API.** Call Core's list endpoint and convert the JSON to CSV: ```bash curl "https://your-core-host/api/links?userId=YOUR_USER_ID" ``` **Option B — Direct SQL.** Export straight from Core's Postgres database: ```sql COPY ( SELECT short_code, title, original_url, ios_app_store_url, android_app_store_url, web_fallback_url, utm_parameters->>'source' AS utm_source, utm_parameters->>'medium' AS utm_medium, utm_parameters->>'campaign' AS utm_campaign, utm_parameters->>'term' AS utm_term, utm_parameters->>'content' AS utm_content FROM links ) TO STDOUT WITH CSV HEADER; ``` Either way, produce a CSV with exactly these headers: ``` short_code,title,original_url,ios_app_store_url,android_app_store_url,web_fallback_url,utm_source,utm_medium,utm_campaign,utm_term,utm_content ``` **Tip:** Download a ready-made template from the **Migration** page in the dashboard to confirm the exact column order before you export. #### Step 3 — Import into Cloud The dashboard's migration tool has a first-class **LinkForty Core** importer — it's the default source. 1. Go to **Migration** in the dashboard. 2. Choose **LinkForty Core** as the source platform. 3. Upload your CSV, then run **Preview / Validate** first — it checks for short-code conflicts without writing anything. 4. Choose a conflict strategy and import: - **Skip** (default) — leave existing links untouched. - **Overwrite** — replace a matching link. - **Rename** — keep both; the new one gets a suffixed short code. **Note: Short codes** Short codes are preserved when possible. Because codes are globally unique across LinkForty, if a code is already taken the link still imports but receives a new generated code — and your original code is recorded on the imported link's metadata. ##### Alternative: recreate links via the API (richer fields) The CSV importer carries **URLs, title, and UTM only**. If your links use **deep link parameters, targeting rules, custom schemes, or per-link attribution windows**, recreate them through the links API instead, which accepts those fields — and set `customCode` to preserve your short code. The links API accepts your **API key** (`Authorization: Bearer `): - [`POST /api/links`](https://docs.linkforty.com/api/endpoints/links/create-link/) — one link (requires a template via `templateId` or `templateSlug`). - [`POST /api/links/bulk-create`](https://docs.linkforty.com/api/endpoints/links/bulk-operations/) — up to 100 links per request. See [Creating Links](https://docs.linkforty.com/guides/links/creating-links/) for the full field reference. #### Step 4 — Repoint your custom domain If your Core links run on your own domain (e.g. `go.yourapp.com`), move it to Cloud so existing links keep resolving: 1. In the dashboard, add your domain under **Settings → Domains**. 2. Update your domain's **CNAME** to point at: ``` go.linkforty.com ``` Cloudflare provisions SSL automatically (usually 5–15 minutes). Once verified, requests to your domain are served by Cloud. **Warning: Cut over deliberately** Keep your Core instance running until DNS has fully propagated and you've confirmed links resolve on Cloud. Changing the CNAME is what actually moves live traffic — do it once you're ready. #### Step 5 — Reconfigure your SDK Point your apps at Cloud instead of your Core server. Only two things change: - **`baseUrl`** → your Cloud link domain (the custom domain from Step 4). - **`apiKey`** → your Cloud key from Step 1. ```ts LinkForty.initialize({ baseUrl: 'https://go.yourapp.com', // your Cloud link domain apiKey: 'YOUR_CLOUD_KEY', // from the dashboard }); ``` The SDK talks to Cloud's `/api/sdk/v1/*` endpoints on that host. See [SDK integration](https://docs.linkforty.com/guides/attribution/sdk-integration/) and the [React Native SDK](https://docs.linkforty.com/sdks/react-native/) for the exact configuration. On the web, drop in the [Web Pixel](https://docs.linkforty.com/sdks/web-pixel/). #### Step 6 — Verify and finish cutover - Tap a few migrated links and confirm they open the right screen, store, or web fallback. - Confirm new clicks and installs are showing up in the Cloud dashboard. - Once you're confident everything resolves on Cloud, decommission your self-hosted Core instance. Your links now run on Cloud — with the managed dashboard, custom domains, team features, and analytics — and no self-hosted infrastructure to maintain. ---