# @linkforty/core > Open-source deeplink management engine built on Fastify + PostgreSQL + optional Redis. Provides smart link routing with device detection, click analytics, UTM tracking, deferred deep linking, device fingerprinting, webhooks, QR codes, link templates, and mobile SDK endpoints. No auth included — bring your own. Install via npm, connect to PostgreSQL, and you have a full deep linking platform. Licensed AGPL-3.0-only. ## Installation ```bash npm install @linkforty/core ``` Requires: - Node.js 20+ - PostgreSQL 14+ - Redis (optional, recommended for caching) ## Quick Start ```typescript import { createServer } from '@linkforty/core'; const server = await createServer({ database: { url: 'postgresql://postgres:password@localhost:5432/linkforty', pool: { min: 2, max: 10 }, }, redis: { url: 'redis://localhost:6379', // optional }, cors: { origin: ['https://yourdomain.com'], }, logger: true, }); await server.listen({ port: 3000, host: '0.0.0.0' }); ``` That's it. The server auto-creates all database tables on first startup, registers all API routes, and starts serving. ## Docker Quick Start ```yaml # docker-compose.yml services: postgres: image: postgres:15 environment: POSTGRES_DB: linkforty POSTGRES_USER: linkforty POSTGRES_PASSWORD: changeme ports: - "5432:5432" volumes: - pgdata:/var/lib/postgresql/data redis: image: redis:7-alpine ports: - "6379:6379" linkforty: image: node:20-alpine working_dir: /app command: node dist/index.js ports: - "3000:3000" environment: DATABASE_URL: postgresql://linkforty:changeme@postgres:5432/linkforty REDIS_URL: redis://redis:6379 PORT: 3000 NODE_ENV: production depends_on: - postgres - redis volumes: pgdata: ``` ```bash docker compose up -d curl http://localhost:3000/health ``` ## Configuration ### createServer(options) ```typescript interface ServerOptions { database?: { url?: string; // PostgreSQL connection string pool?: { min?: number; // Default: 2 max?: number; // Default: 10 }; }; redis?: { url: string; // Redis connection string }; cors?: { origin: string | string[]; }; logger?: boolean; // Enable Fastify logger } ``` ### Environment Variables ```bash # Database (PostgreSQL) DATABASE_URL=postgresql://linkforty:changeme@localhost:5432/linkforty # Redis (optional — falls back to database on miss or error) REDIS_URL=redis://localhost:6379 # Server PORT=3000 HOST=0.0.0.0 NODE_ENV=production CORS_ORIGIN=* # Short link domain (used in QR codes and SDK responses) SHORTLINK_DOMAIN=https://go.yourdomain.com # iOS Universal Links (optional) IOS_TEAM_ID=ABC123XYZ IOS_BUNDLE_ID=com.yourcompany.yourapp # Android App Links (optional) ANDROID_PACKAGE_NAME=com.yourcompany.yourapp ANDROID_SHA256_FINGERPRINTS=AA:BB:CC:DD:... ``` ## Module Exports ```typescript import { createServer } from '@linkforty/core'; // Server factory import { generateShortCode } from '@linkforty/core/utils'; // Utility functions import { db } from '@linkforty/core/database'; // PostgreSQL pool import { linkRoutes } from '@linkforty/core/routes'; // Fastify route plugins import type { Link, AnalyticsData } from '@linkforty/core/types'; // TypeScript types ``` ## TypeScript Types ### Link ```typescript interface Link { id: string; // UUID userId?: string; // Optional (multi-tenant scoping) template_id?: string; template_slug?: string; short_code: string; // Unique, immutable after creation original_url: string; title?: string; description?: string; // Platform-specific URLs ios_app_store_url?: string; android_app_store_url?: string; web_fallback_url?: string; // Deep linking app_scheme?: string; // URI scheme (e.g., "myapp") ios_universal_link?: string; android_app_link?: string; deep_link_path?: string; // In-app destination (e.g., "/product/123") deep_link_parameters?: Record; // Analytics utmParameters?: UTMParameters; targeting_rules?: TargetingRules; // Social preview (Open Graph) og_title?: string; og_description?: string; og_image_url?: string; og_type?: string; // Lifecycle attribution_window_hours?: number; // Default: 168 (7 days) is_active: boolean; expires_at?: string; created_at: string; updated_at: string; click_count?: number; } interface UTMParameters { source?: string; medium?: string; campaign?: string; term?: string; content?: string; } interface TargetingRules { countries?: string[]; // ISO country codes (e.g., ["US", "GB"]) devices?: ('ios' | 'android' | 'web')[]; languages?: string[]; // BCP 47 codes (e.g., ["en", "es"]) } ``` ### Create/Update Link Request ```typescript interface CreateLinkRequest { userId?: string; templateId?: string; originalUrl: string; // Required, must be valid URL title?: string; description?: string; iosAppStoreUrl?: string; androidAppStoreUrl?: string; webFallbackUrl?: string; appScheme?: string; iosUniversalLink?: string; androidAppLink?: string; deepLinkPath?: string; deepLinkParameters?: Record; utmParameters?: UTMParameters; targetingRules?: TargetingRules; ogTitle?: string; ogDescription?: string; ogImageUrl?: string; ogType?: string; attributionWindowHours?: number; // 1-2160 customCode?: string; // Custom short code (auto-generated if omitted) expiresAt?: string; // ISO 8601 datetime } // UpdateLinkRequest is Partial plus: interface UpdateLinkRequest extends Partial { isActive?: boolean; } ``` ### Link Template ```typescript interface LinkTemplate { id: string; userId?: string; name: string; slug: string; // Auto-generated 8-char alphanumeric description?: string; settings: LinkTemplateSettings; is_default: boolean; created_at: string; updated_at: string; } interface LinkTemplateSettings { defaultIosUrl?: string; defaultAndroidUrl?: string; defaultWebFallbackUrl?: string; defaultAttributionWindowHours?: number; utmParameters?: UTMParameters; targetingRules?: TargetingRules; expiresAfterDays?: number; } ``` ### Analytics ```typescript interface AnalyticsData { totalClicks: number; uniqueClicks: number; clicksByDate: Array<{ date: string; clicks: number }>; clicksByCountry: Array<{ country: string; countryCode: string; clicks: number }>; clicksByDevice: Array<{ device: string; clicks: number }>; clicksByPlatform: Array<{ platform: string; clicks: number }>; clicksByBrowser: Array<{ browser: string; clicks: number }>; clicksByHour: Array<{ hour: number; clicks: number }>; clicksByUtmSource: Array<{ source: string; clicks: number }>; clicksByUtmMedium: Array<{ medium: string; clicks: number }>; clicksByUtmCampaign: Array<{ campaign: string; clicks: number }>; clicksByReferrer: Array<{ source: string; clicks: number }>; topLinks: Array<{ id: string; shortCode: string; title: string | null; originalUrl: string; totalClicks: number; uniqueClicks: number; }>; } ``` ### Webhook ```typescript type WebhookEvent = 'click_event' | 'install_event' | 'conversion_event'; interface Webhook { id: string; user_id?: string; name: string; url: string; secret: string; // Auto-generated, HMAC-SHA256 signing key events: WebhookEvent[]; is_active: boolean; retry_count: number; // 1-10 timeout_ms: number; // 1000-60000 headers: Record; created_at: string; updated_at: string; } ``` ## API Endpoints All endpoints accept JSON request/response bodies. The `userId` query parameter is optional on all routes — when provided, queries are scoped to that user (multi-tenant mode). When omitted, all records are accessible (single-tenant mode). ### Link Management #### POST /api/links — Create Link ```bash curl -X POST http://localhost:3000/api/links \ -H "Content-Type: application/json" \ -d '{ "originalUrl": "https://example.com/product/789", "title": "Summer Campaign", "iosAppStoreUrl": "https://apps.apple.com/app/id123456789", "androidAppStoreUrl": "https://play.google.com/store/apps/details?id=com.example.app", "webFallbackUrl": "https://example.com/product/789", "deepLinkParameters": { "route": "product", "productId": "789" }, "utmParameters": { "source": "instagram", "medium": "social", "campaign": "summer" }, "templateId": "550e8400-e29b-41d4-a716-446655440000" }' ``` Response (201): ```json { "id": "uuid", "shortCode": "abc12345", "originalUrl": "https://example.com/product/789", "title": "Summer Campaign", "deepLinkParameters": { "route": "product", "productId": "789" }, "utmParameters": { "source": "instagram", "medium": "social", "campaign": "summer" }, "isActive": true, "clickCount": 0, "createdAt": "2025-01-15T10:30:00Z", "updatedAt": "2025-01-15T10:30:00Z" } ``` #### GET /api/links — List Links ```bash curl "http://localhost:3000/api/links?userId=optional-user-id" ``` #### GET /api/links/:id — Get Link ```bash curl "http://localhost:3000/api/links/uuid" ``` #### PUT /api/links/:id — Update Link ```bash curl -X PUT "http://localhost:3000/api/links/uuid" \ -H "Content-Type: application/json" \ -d '{ "title": "Updated Title", "isActive": false }' ``` #### DELETE /api/links/:id — Delete Link ```bash curl -X DELETE "http://localhost:3000/api/links/uuid" ``` Response: `{ "success": true }` #### POST /api/links/:id/duplicate — Clone Link ```bash curl -X POST "http://localhost:3000/api/links/uuid/duplicate" ``` ### Templates #### POST /api/templates — Create Template ```bash curl -X POST http://localhost:3000/api/templates \ -H "Content-Type: application/json" \ -d '{ "name": "E-commerce Links", "settings": { "defaultIosUrl": "https://apps.apple.com/app/id123", "defaultAndroidUrl": "https://play.google.com/store/apps/details?id=com.example", "defaultWebFallbackUrl": "https://example.com", "defaultAttributionWindowHours": 168, "utmParameters": { "source": "app", "medium": "deeplink" } }, "isDefault": true }' ``` #### GET /api/templates — List Templates #### GET /api/templates/:id — Get Template #### PUT /api/templates/:id — Update Template #### DELETE /api/templates/:id — Delete Template #### PUT /api/templates/:id/set-default — Set Default Template ### Analytics #### GET /api/analytics/overview — Aggregate Analytics ```bash curl "http://localhost:3000/api/analytics/overview?days=30" ``` Returns full `AnalyticsData` object (see types above). #### GET /api/analytics/links/:linkId — Link-Specific Analytics ```bash curl "http://localhost:3000/api/analytics/links/uuid?days=7" ``` ### Webhooks #### POST /api/webhooks — Create Webhook ```bash curl -X POST http://localhost:3000/api/webhooks \ -H "Content-Type: application/json" \ -d '{ "name": "My Webhook", "url": "https://example.com/webhooks/linkforty", "events": ["click_event", "install_event", "conversion_event"], "retryCount": 3, "timeoutMs": 10000 }' ``` Response includes auto-generated `secret` for HMAC verification. #### GET /api/webhooks — List Webhooks #### GET /api/webhooks/:id — Get Webhook (includes secret) #### PUT /api/webhooks/:id — Update Webhook #### DELETE /api/webhooks/:id — Delete Webhook #### POST /api/webhooks/:id/test — Send Test Payload **Webhook signature verification:** ```typescript import crypto from 'crypto'; function verifyWebhook(body: string, signature: string, secret: string): boolean { const expected = crypto.createHmac('sha256', secret).update(body).digest('hex'); return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(`sha256=${expected}`)); } // Signature header: X-LinkForty-Signature ``` ### Redirects (Public) #### GET /:shortCode — Follow Short Link Redirects (302) based on device: 1. iOS device → `ios_app_store_url` 2. Android device → `android_app_store_url` 3. Desktop/other → `web_fallback_url` → `original_url` UTM parameters are appended to the redirect URL. Click is tracked asynchronously. #### GET /:templateSlug/:shortCode — Template-Based Redirect Same behavior, resolves via template slug + short code. ### QR Codes #### GET /api/links/:id/qr — Generate QR Code ```bash # PNG (default) curl "http://localhost:3000/api/links/uuid/qr" -o qr.png # SVG curl "http://localhost:3000/api/links/uuid/qr?format=svg" -o qr.svg ``` ### Mobile SDK Endpoints (Public) These are called by the LinkForty mobile SDKs (`@linkforty/mobile-sdk-react-native`, `@linkforty/mobile-sdk-expo`, iOS SDK, Android SDK). #### POST /api/sdk/v1/install — Report App Install ```bash curl -X POST http://localhost:3000/api/sdk/v1/install \ -H "Content-Type: application/json" \ -d '{ "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", "timezone": "America/New_York", "language": "en-US", "screenWidth": 390, "screenHeight": 844, "platform": "iOS", "platformVersion": "17.0", "attributionWindowHours": 168 }' ``` Response: ```json { "installId": "uuid", "attributed": true, "confidenceScore": 85, "matchedFactors": ["ip", "user_agent", "timezone", "language", "screen"], "deepLinkData": { "shortCode": "abc123", "originalUrl": "https://example.com/product/789", "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.example", "webFallbackUrl": "https://example.com/product/789", "utmParameters": { "source": "instagram" }, "deepLinkParameters": { "route": "product", "productId": "789" } } } ``` #### GET /api/sdk/v1/resolve/:shortCode — Resolve Deep Link Data ```bash curl "http://localhost:3000/api/sdk/v1/resolve/abc123?fp_tz=America/New_York&fp_platform=ios" ``` Response: ```json { "shortCode": "abc123", "linkId": "uuid", "deepLinkPath": "/product/789", "appScheme": "myapp", "iosUrl": "https://apps.apple.com/app/id123", "androidUrl": "https://play.google.com/store/apps/details?id=com.example", "webUrl": "https://example.com/product/789", "utmParameters": { "source": "instagram" }, "customParameters": { "route": "product", "productId": "789" }, "clickedAt": "2025-01-15T10:30:00Z" } ``` #### POST /api/sdk/v1/event — Track In-App Event ```bash curl -X POST http://localhost:3000/api/sdk/v1/event \ -H "Content-Type: application/json" \ -d '{ "installId": "uuid", "eventName": "purchase", "eventData": { "amount": 29.99, "currency": "USD" } }' ``` Response: `{ "eventId": "uuid", "acknowledged": true }` ### Well-Known (Auto-Served) #### GET /.well-known/apple-app-site-association Serves the AASA file for iOS Universal Links. Configure via `IOS_TEAM_ID` and `IOS_BUNDLE_ID` env vars. #### GET /.well-known/assetlinks.json Serves Digital Asset Links for Android App Links. Configure via `ANDROID_PACKAGE_NAME` and `ANDROID_SHA256_FINGERPRINTS` env vars. ## Redirect Flow When a user clicks a short link (e.g., `https://go.yourdomain.com/abc123`): 1. Check Redis cache (`link:{shortCode}`, 5-min TTL) 2. Fall back to PostgreSQL if cache miss or Redis unavailable 3. Evaluate targeting rules (countries, devices, languages) — return 404 if no match 4. Track click asynchronously (does not block redirect) 5. Select destination URL based on device (iOS → Android → web → original) 6. Append UTM parameters to destination URL 7. Return 302 redirect ## Fingerprint Attribution When a mobile SDK reports an install, Core matches it to a previous click using probabilistic fingerprinting: | Factor | Weight | |--------|--------| | IP address | 40 points | | User agent | 30 points | | Timezone | 10 points | | Language | 10 points | | Screen resolution | 10 points | A match requires 70+ confidence score out of 100. Default attribution window is 168 hours (7 days), configurable per link (1-2160 hours). ## Database Schema Tables are auto-created on first startup by `initializeDatabase()`. No separate migration step needed. | Table | Purpose | |-------|---------| | `link_templates` | Reusable link configuration templates | | `links` | Short links with all configuration | | `click_events` | Click analytics with geolocation | | `device_fingerprints` | Fingerprint components for attribution matching | | `install_events` | Mobile app install tracking | | `in_app_events` | Conversion events from mobile apps | | `webhooks` | Webhook endpoint configurations | All tables use UUID primary keys (`gen_random_uuid()`), `created_at`/`updated_at` timestamps, and snake_case column names. ## Extending Core Core returns a standard Fastify instance, so you can add custom routes, plugins, and hooks: ```typescript import { createServer } from '@linkforty/core'; const server = await createServer({ database: { url: process.env.DATABASE_URL }, logger: true, }); // Add custom authentication server.addHook('onRequest', async (request, reply) => { const apiKey = request.headers['x-api-key']; if (!apiKey || apiKey !== process.env.API_KEY) { reply.code(401).send({ error: 'Unauthorized' }); } }); // Add custom routes server.get('/api/custom/stats', async (request, reply) => { const { db } = await import('@linkforty/core/database'); const result = await db.query('SELECT COUNT(*) FROM links'); return { totalLinks: parseInt(result.rows[0].count) }; }); await server.listen({ port: 3000, host: '0.0.0.0' }); ``` ## Complete Self-Hosted Server Example ```typescript // server.ts import 'dotenv/config'; import { createServer } from '@linkforty/core'; async function start() { const server = await createServer({ database: { url: process.env.DATABASE_URL || 'postgresql://linkforty:changeme@localhost:5432/linkforty', pool: { min: 2, max: 10 }, }, redis: process.env.REDIS_URL ? { url: process.env.REDIS_URL } : undefined, cors: { origin: process.env.CORS_ORIGIN?.split(',') || ['*'], }, logger: true, }); const port = parseInt(process.env.PORT || '3000'); const host = process.env.HOST || '0.0.0.0'; await server.listen({ port, host }); console.log(`LinkForty Core running at http://${host}:${port}`); } start().catch((err) => { console.error('Failed to start:', err); process.exit(1); }); ``` ```json // package.json { "type": "module", "scripts": { "start": "node --loader tsx server.ts", "dev": "tsx watch server.ts" }, "dependencies": { "@linkforty/core": "^1.6.0", "dotenv": "^16.3.0" }, "devDependencies": { "tsx": "^4.0.0", "typescript": "^5.2.0" } } ``` ## Utility Functions ```typescript import { generateShortCode, parseUserAgent, getLocationFromIP, buildRedirectUrl, detectDevice, } from '@linkforty/core/utils'; generateShortCode(8); // → "aB3kX9mQ" (nanoid-based, configurable length) parseUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0...'); // → { deviceType: "mobile", platform: "iOS", platformVersion: "17.0", browser: "Safari" } getLocationFromIP('8.8.8.8'); // → { countryCode: "US", countryName: "United States", city: "Mountain View", ... } detectDevice('Mozilla/5.0 (iPhone...)'); // → "ios" buildRedirectUrl('https://example.com', { source: 'email', medium: 'campaign' }); // → "https://example.com?utm_source=email&utm_medium=campaign" ``` ## Error Responses All errors follow this format: ```json { "error": "Error message", "statusCode": 400 } ``` | Status | Meaning | |--------|---------| | 400 | Validation error (bad request body) | | 404 | Link/resource not found, or targeting rules excluded user | | 500 | Internal server error | ## Real-Time Events Core emits click events via an internal event emitter and optional WebSocket: ```typescript import { subscribeToClickEvents } from '@linkforty/core'; const unsubscribe = subscribeToClickEvents((event) => { console.log('Click:', event.shortCode, event.deviceType, event.country); }); // WebSocket endpoint: ws://localhost:3000/api/debug/live ```