@linkforty/core 1.44.2 → 1.44.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -588
- package/llms.txt +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
**Open-source alternative to Branch.io, AppsFlyer OneLink, and Firebase Dynamic Links**
|
|
7
7
|
|
|
8
|
-
Self-hosted deep linking engine with device detection, analytics, deferred deep linking, and smart routing. No per-click pricing, no vendor lock-in, full data ownership — runs on your own PostgreSQL. Firebase Dynamic Links shut down in August 2025; LinkForty is a production-ready, open-source
|
|
8
|
+
Self-hosted deep linking engine with device detection, analytics, deferred deep linking, and smart routing. No per-click pricing, no vendor lock-in, full data ownership — runs on your own PostgreSQL. Firebase Dynamic Links shut down in August 2025; LinkForty is a production-ready, open-source alternative you can deploy today.
|
|
9
9
|
</div>
|
|
10
10
|
|
|
11
11
|
[](https://www.npmjs.com/package/@linkforty/core)
|
|
@@ -21,25 +21,17 @@
|
|
|
21
21
|
- **No per-click pricing** — No usage-based fees, no monthly minimums, no enterprise sales calls
|
|
22
22
|
- **Full data ownership** — All click data, analytics, and attribution stored in your PostgreSQL database
|
|
23
23
|
- **Privacy-first** — No third-party data sharing, no tracking pixels, your users' data stays with you
|
|
24
|
-
- **
|
|
25
|
-
- **Firebase Dynamic Links
|
|
24
|
+
- **REST API and mobile SDKs** — SDKs for React Native, Expo, Flutter, iOS (Swift), and Android (Kotlin)
|
|
25
|
+
- **Firebase Dynamic Links alternative** — Google shut down Firebase Dynamic Links in August 2025. LinkForty is a self-hosted, open-source alternative
|
|
26
26
|
|
|
27
27
|
### How LinkForty Compares
|
|
28
28
|
|
|
29
|
-
|
|
|
30
|
-
|
|
31
|
-
| **Open
|
|
32
|
-
| **Self-
|
|
33
|
-
| **
|
|
34
|
-
| **
|
|
35
|
-
| **Device Detection & Routing** | Yes | Yes | Yes | Was supported |
|
|
36
|
-
| **Click Analytics** | Yes | Yes | Yes | Basic |
|
|
37
|
-
| **QR Code Generation** | Built-in | No | No | No |
|
|
38
|
-
| **Webhooks** | Yes | Enterprise only | Enterprise only | No |
|
|
39
|
-
| **iOS Universal Links** | Yes | Yes | Yes | Was supported |
|
|
40
|
-
| **Android App Links** | Yes | Yes | Yes | Was supported |
|
|
41
|
-
| **UTM Parameter Tracking** | Yes | Yes | Custom params | Was supported |
|
|
42
|
-
| **Custom Domains** | Yes | Enterprise only | Enterprise only | No |
|
|
29
|
+
| | LinkForty Core | Branch | AppsFlyer |
|
|
30
|
+
|---|----------------|--------|-----------|
|
|
31
|
+
| **Open source** | Yes (AGPL-3.0) | No | No |
|
|
32
|
+
| **Self-hosted** | Yes | No | No |
|
|
33
|
+
| **Where your data lives** | Your PostgreSQL | Branch's servers | AppsFlyer's servers |
|
|
34
|
+
| **Per-click pricing** | None | Check their pricing | Check their pricing |
|
|
43
35
|
|
|
44
36
|
## Features
|
|
45
37
|
|
|
@@ -243,70 +235,7 @@ GET /:shortCode
|
|
|
243
235
|
GET /:templateSlug/:shortCode
|
|
244
236
|
```
|
|
245
237
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
**Not-found page:** When a link can't be served, a browser gets a small HTML page ("Link not found") with status 404, `noindex`, `Cache-Control: no-store` and a content-security policy that allows no script. That covers an unknown code, an expired or switched-off link, a link on another organization's host, a visitor outside the link's targeting rules, and a link with no destination for the visitor's device. All of these render the same page, so an expired link can't be told apart from a code that never existed. Clients that don't send `Accept: text/html` (API callers, scripts) keep the JSON body: `{ "error": "Link not found" }`, or `{ "error": "No destination URL configured for this link" }` for a link with no destination. A link withdrawn for abuse keeps its own 410 notice.
|
|
249
|
-
|
|
250
|
-
You can change the page or send browsers somewhere else instead, without writing code. The first of these that is set wins:
|
|
251
|
-
|
|
252
|
-
1. **The organization's settings**, `organizations.settings.notFound`, on a host that belongs to the organization. On a shared host, only a live link that has nothing for this visitor (a targeting miss, no destination) uses its organization's settings. An unknown, expired or switched-off code there uses the instance's.
|
|
253
|
-
|
|
254
|
-
```json
|
|
255
|
-
{
|
|
256
|
-
"notFound": {
|
|
257
|
-
"redirectUrl": "https://example.com", // send browsers here (302); the rest is ignored
|
|
258
|
-
"title": "This link has expired",
|
|
259
|
-
"message": "Open the app to continue.",
|
|
260
|
-
"logoUrl": "https://cdn.example/logo.png",
|
|
261
|
-
"accentColor": "#0f766e", // the button
|
|
262
|
-
"backgroundColor": "#f6f7f8", // light mode only
|
|
263
|
-
"buttonLabel": "Get the app", // shown only with buttonUrl
|
|
264
|
-
"buttonUrl": "https://example.com/app"
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
Changes show within a minute.
|
|
270
|
-
2. **The instance's defaults**: the `notFound` section of `linkforty.config.json` (see [Configuration file](#configuration-file)), with the same fields.
|
|
271
|
-
3. **The built-in page.**
|
|
272
|
-
|
|
273
|
-
URLs must be http(s) and colors hex; anything else is dropped, and all text is escaped. The `notFound` option below decides in code ahead of all three.
|
|
274
|
-
|
|
275
|
-
**Mobile interstitial:** When a link has `appScheme` configured and a store fallback URL (iOS App Store or Google Play), mobile requests receive a smart interstitial page instead of a raw 302 redirect. The interstitial tries to open the app via URI scheme and falls back to the app store after 1.5 seconds. This handles the case where a 302 to a custom URI scheme fails silently when the app is not installed. URL fragments are preserved through the redirect, enabling patterns like E2E encryption where the decryption key lives in the fragment.
|
|
276
|
-
|
|
277
|
-
**Launchpad page (desktop):** A link to app-only content has nowhere to send a desktop visitor. When the chain resolves no web destination (link → template → workspace `appConfig`), a desktop request gets a hosted landing page instead of an error: the link's title, description and image (`ogTitle` / `ogDescription` / `ogImageUrl`, falling back to `title` / `description`), the app's icon and name, the official App Store / Google Play badges (embedded in the page, so it makes no third-party request), and a QR code so the visitor can finish on their phone. The page is `noindex`, sent with `Cache-Control: no-store` and a content-security policy that allows no script without a per-response nonce, and it never navigates on its own — no timers, no automatic scheme attempts.
|
|
278
|
-
|
|
279
|
-
When the link does have a web destination, the page offers it as a "Continue on the web" link, and the title links there too, so a visitor is never trapped. By default it appears **only** where the alternative is nothing; a link that resolves to a destination still gets its 302. Two knobs change that:
|
|
280
|
-
|
|
281
|
-
- `organizations.settings.launchpad` (per workspace):
|
|
282
|
-
|
|
283
|
-
```json
|
|
284
|
-
{
|
|
285
|
-
"launchpad": {
|
|
286
|
-
"desktop": "no-destination", // "no-destination" (default) | "always" | "off"
|
|
287
|
-
"mobile": "store", // "store" (default) | "page"
|
|
288
|
-
"appName": "Example App",
|
|
289
|
-
"appIconUrl": "https://cdn.example/icon.png",
|
|
290
|
-
"appWebsiteUrl": "https://example.com", // optional; the header's icon and name link here
|
|
291
|
-
"accentColor": "#0f766e", // buttons, the web link, focus rings
|
|
292
|
-
"backgroundColor": "#101418" // optional; fixes the page's palette (see below)
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
```
|
|
296
|
-
|
|
297
|
-
`appWebsiteUrl` is deliberately explicit rather than derived from a link's web fallback, which may be someone else's page (an event listing, a video). Unset, the header is not a link.
|
|
298
|
-
|
|
299
|
-
Without `backgroundColor` the page follows the visitor's light/dark preference. With one, the background is fixed and the text, surfaces and borders are derived from it — a light or a dark brand color both stay legible, and the visitor's theme no longer changes the page.
|
|
300
|
-
|
|
301
|
-
- The `launchpad` section of `linkforty.config.json` (per instance): the same fields except `templateId`, which names a host-rendered template and stays per workspace. It's the default for every link, including links with no workspace. A workspace's `settings.launchpad` overrides it **field by field**: a workspace that sets only `accentColor` keeps the instance's `appName`, icon and modes. (The not-found page works differently: a workspace's `settings.notFound` replaces the instance's as a whole.)
|
|
302
|
-
|
|
303
|
-
- `links.launchpad_mode` (per link, `launchpadMode` on the links API): `inherit` (default), `on` or `off`. The link's value wins over the workspace's and the instance's.
|
|
304
|
-
|
|
305
|
-
With `desktop: "off"` (or `launchpad_mode: "off"`), a link with no web destination gets the plain "This link opens in an app" page instead.
|
|
306
|
-
|
|
307
|
-
**Launchpad page (mobile):** With `mobile: "page"`, a phone visitor whose platform has no Universal Link / App Link on the link gets the same page instead of the store redirect or the scheme interstitial: an "Open in app" button when the link has a URI scheme (a tap, not a navigation — the URL fragment is appended on tap), the visitor's own store button, and the web fallback as a "Continue on the web" link. That last one matters inside in-app browsers (Gmail, Facebook, Instagram…), which bypass Universal Links: the store redirect would send someone who *has* the app to the store, so the redirect path prefers the web fallback there to give the OS a second chance on the next hop — and the page keeps that hop available as a tap. Links that carry a Universal Link / App Link keep their 302 regardless, because the OS resolves the installed case before the request reaches this server. `launchpad_mode: "off"` opts a link out; `"on"` does not force the page onto a workspace that chose `"store"`. The default, `"store"`, is unchanged behaviour.
|
|
308
|
-
|
|
309
|
-
Two hooks on `RedirectRouteOptions` let a host application extend the page without forking it — see [Server Options](#server-options).
|
|
238
|
+
Redirects users to the appropriate URL based on device type (iOS/Android/web), evaluates targeting rules, and tracks the click asynchronously. See [Redirects](docs/redirects.md) for the not-found page, the mobile interstitial and the Launchpad page.
|
|
310
239
|
|
|
311
240
|
### QR Codes
|
|
312
241
|
|
|
@@ -386,458 +315,17 @@ GET /.well-known/assetlinks.json # Android App Links
|
|
|
386
315
|
GET /:shortCode/preview # OG meta tag page for social scrapers
|
|
387
316
|
```
|
|
388
317
|
|
|
389
|
-
##
|
|
390
|
-
|
|
391
|
-
### Server Options
|
|
392
|
-
|
|
393
|
-
```typescript
|
|
394
|
-
interface ServerOptions {
|
|
395
|
-
database?: {
|
|
396
|
-
url?: string; // PostgreSQL connection string
|
|
397
|
-
pool?: {
|
|
398
|
-
min?: number; // Minimum pool connections (default: 2)
|
|
399
|
-
max?: number; // Maximum pool connections (default: 10)
|
|
400
|
-
};
|
|
401
|
-
};
|
|
402
|
-
redis?: {
|
|
403
|
-
url: string; // Redis connection string (optional)
|
|
404
|
-
};
|
|
405
|
-
cors?: {
|
|
406
|
-
origin: string | string[]; // CORS allowed origins (default: '*')
|
|
407
|
-
};
|
|
408
|
-
logger?: boolean; // Enable Fastify logger (default: true)
|
|
409
|
-
trustProxy?: boolean | number; // Trust X-Forwarded-For when behind a proxy (default: false)
|
|
410
|
-
redirect?: RedirectRouteOptions; // See below
|
|
411
|
-
sdk?: SdkRouteOptions; // See below
|
|
412
|
-
config?: LinkFortyConfig; // linkforty.config.json, from loadConfig()
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
interface RedirectRouteOptions {
|
|
416
|
-
abuseReportUrl?: string; // Linked from the link-safety warning page
|
|
417
|
-
launchpad?: {
|
|
418
|
-
// Supply richer content for the launchpad page than the link row carries:
|
|
419
|
-
// a rendered share image, a templated hero. Return null for the default.
|
|
420
|
-
// A hook that throws is logged and treated as null — the page never fails
|
|
421
|
-
// because of it. `heroHtml` is inserted unescaped: render it yourself,
|
|
422
|
-
// never from end-user input.
|
|
423
|
-
// `context.webUrl` is the link's web destination, decorated exactly as
|
|
424
|
-
// "Continue on the web" uses it (or null), so a hero that draws its own
|
|
425
|
-
// title can link it the way the default page does.
|
|
426
|
-
resolveContent?: (link, settings, context: { webUrl: string | null }) => Promise<LaunchpadContent | null>;
|
|
427
|
-
// Endpoint that receives `{ linkId, event }` beacons from the page —
|
|
428
|
-
// "view" on load; "cta_ios" / "cta_android" / "cta_open" / "cta_web" /
|
|
429
|
-
// "cta_app_website" on tap. No cookies or identifiers. Unset means the
|
|
430
|
-
// page sends nothing. If you validate event names, accept all of these.
|
|
431
|
-
beaconUrl?: string;
|
|
432
|
-
};
|
|
433
|
-
// Return true when the request is on a host reserved for web links. Every
|
|
434
|
-
// link on it then sends every device to its web destination with a plain
|
|
435
|
-
// 302: no launchpad page, no scheme attempt, no store. Don't serve
|
|
436
|
-
// association files on that host, so no installed app can claim its URLs.
|
|
437
|
-
// A hook that throws is logged and treated as false.
|
|
438
|
-
isWebLinkRequest?: (request) => Promise<boolean> | boolean;
|
|
439
|
-
// The instance's not-found page or redirect, used when the organization has
|
|
440
|
-
// no settings.notFound of its own. createServer() fills it from the
|
|
441
|
-
// `notFound` section of the config file (ServerOptions.config).
|
|
442
|
-
notFoundDefaults?: NotFoundSettings;
|
|
443
|
-
// Decide the not-found response in code, ahead of the organization's
|
|
444
|
-
// settings and notFoundDefaults: page content (title,
|
|
445
|
-
// message, logoUrl, accentColor, backgroundColor, buttonLabel, buttonUrl — all
|
|
446
|
-
// optional, all escaped; non-http(s) URLs and non-hex colors are dropped),
|
|
447
|
-
// a URL to 302 to (http(s) only), or null to fall through. Called only
|
|
448
|
-
// for requests that accept HTML, and never for a link withdrawn for abuse.
|
|
449
|
-
// `context.reason` is "unknown" | "disabled" | "not_targeted" |
|
|
450
|
-
// "no_destination"; `context.link` is the link row (null for "unknown");
|
|
451
|
-
// `context.scope` is the host's organization, if any. Use the reason to
|
|
452
|
-
// decide whose settings apply, not to vary the page: an expired link must
|
|
453
|
-
// look like a code that never existed. A hook that throws is logged and
|
|
454
|
-
// treated as null.
|
|
455
|
-
notFound?: (request, context: { reason, link, scope }) =>
|
|
456
|
-
Promise<NotFoundResolution | null> | NotFoundResolution | null;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
interface SdkRouteOptions {
|
|
460
|
-
// Narrow the clicks an install can be attributed to. A server shared by
|
|
461
|
-
// several apps resolves the installing app (from the SDK's `appToken`, say)
|
|
462
|
-
// and returns its organization, so an install only ever matches — and
|
|
463
|
-
// receives the deep link of — that organization's links. Return null to
|
|
464
|
-
// match against every click, as when the option is absent. A hook that
|
|
465
|
-
// throws is logged and treated as null.
|
|
466
|
-
resolveInstallScope?: (request, body: { appToken?: string }) =>
|
|
467
|
-
Promise<{ organizationId?: string } | null> | { organizationId?: string } | null;
|
|
468
|
-
}
|
|
469
|
-
```
|
|
470
|
-
|
|
471
|
-
Install matching only considers clicks from the installing device's own network (its IPv4 /24 or IPv6 /64), since no other click can reach the confidence threshold, so the attribution window holds however much traffic the server takes. It relies on the `idx_fingerprints_ip` index, which `initializeDatabase()` builds concurrently on first start so click writes are never blocked.
|
|
472
|
-
|
|
473
|
-
`renderLaunchpadPage()`, `defaultLaunchpadContent()` and `shouldServeLaunchpadOnDesktop()` are exported so a host can render the same page elsewhere (a settings preview, for instance). `renderNotFoundPage()` is exported for the same reason, and for a host that answers some requests before they reach the redirect route.
|
|
474
|
-
|
|
475
|
-
### Configuration file
|
|
476
|
-
|
|
477
|
-
Core splits its configuration the way most self-hosted platforms do:
|
|
478
|
-
|
|
479
|
-
- **Environment variables** hold infrastructure and secrets: `DATABASE_URL`, `REDIS_URL`, `PORT`, `TRUST_PROXY`, and so on.
|
|
480
|
-
- **`linkforty.config.json`** holds the instance's defaults and behavior, including text a visitor reads. Today that's the `notFound` and `launchpad` sections; see [linkforty.config.example.json](linkforty.config.example.json) and [DOCKER.md](DOCKER.md#configuration-file) for every setting.
|
|
481
|
-
- **`organizations.settings`** holds per-organization values, which take precedence over the file where both apply.
|
|
482
|
-
|
|
483
|
-
The file is optional. Core reads the path in `LINKFORTY_CONFIG`, or `linkforty.config.json` in the working directory. A file with an invalid value or unknown setting stops the server at startup with the setting named, rather than starting with it ignored. The Docker image does this for you. When you create the server yourself, load it and pass it in:
|
|
484
|
-
|
|
485
|
-
```typescript
|
|
486
|
-
import { createServer, loadConfig } from '@linkforty/core';
|
|
487
|
-
|
|
488
|
-
const { config } = loadConfig(); // throws ConfigError on a bad file
|
|
489
|
-
const server = await createServer({ database: { url: process.env.DATABASE_URL }, config });
|
|
490
|
-
```
|
|
491
|
-
|
|
492
|
-
Options passed in code (such as `redirect.notFoundDefaults`) take precedence over the file.
|
|
493
|
-
|
|
494
|
-
### Running behind a reverse proxy
|
|
495
|
-
|
|
496
|
-
When Core runs behind a reverse proxy, CDN, or load balancer, set `trustProxy` so the server uses the real client IP from `X-Forwarded-For` for redirect targeting, geo, attribution, and fingerprinting. Pass it when creating the server (e.g. `trustProxy: true` or a number of proxy hops) or set the `TRUST_PROXY` environment variable (e.g. `TRUST_PROXY=1`). Client-provided `ipAddress` in the SDK install request body is **not** used as the trusted IP; it is optional debug metadata only and must not be relied on for attribution.
|
|
497
|
-
|
|
498
|
-
### Email security scanner detection
|
|
499
|
-
|
|
500
|
-
Mail security services (Microsoft Defender Safe Links, Mimecast, Proofpoint and others) open every link in a delivered email to check where it leads. They send a normal desktop browser user agent, so without help those visits count as human clicks, and on links sent by email they can outnumber the people.
|
|
501
|
-
|
|
502
|
-
Core can recognize them by the network they come from. Give it a MaxMind [GeoLite2-ASN](https://dev.maxmind.com/geoip/geolite2-free-geolocation-data) database (free with a MaxMind account; the license doesn't allow shipping it with Core, and MaxMind updates it weekly):
|
|
503
|
-
|
|
504
|
-
```bash
|
|
505
|
-
ASN_DB_PATH=/data/GeoLite2-ASN.mmdb
|
|
506
|
-
```
|
|
507
|
-
|
|
508
|
-
or pass `botDetection: { asnDatabasePath }` to `createServer()`. A click is then recorded with `is_bot = true` and `bot_reason = 'email_scanner'` when it comes from a desktop browser on Microsoft, Amazon or Google's networks (ASNs 8075, 16509, 14618, 15169, 396982). Mobile browsers, app HTTP clients and SDK requests from those networks are not flagged. Without the database the rule is off; a file that can't be opened is logged and the server starts without it.
|
|
509
|
-
|
|
510
|
-
To change the networks, set `botDetection.emailScannerAsns` in `linkforty.config.json`. It replaces the built-in list, and an empty list turns the rule off. Networks iCloud Private Relay exits through (Apple, Cloudflare, Akamai, Fastly) are rejected, since real Safari users would be flagged.
|
|
511
|
-
|
|
512
|
-
A host that registers the routes itself instead of calling `createServer()` turns the rule on with `loadScannerDetection(path, { asns })`.
|
|
513
|
-
|
|
514
|
-
### Environment Variables
|
|
515
|
-
|
|
516
|
-
```bash
|
|
517
|
-
DATABASE_URL=postgresql://localhost/linkforty
|
|
518
|
-
REDIS_URL=redis://localhost:6379
|
|
519
|
-
PORT=3000
|
|
520
|
-
NODE_ENV=production
|
|
521
|
-
CORS_ORIGIN=*
|
|
522
|
-
# When behind a reverse proxy: TRUST_PROXY=1 (or number of hops) so client IP is read from X-Forwarded-For
|
|
523
|
-
# TRUST_PROXY=1
|
|
524
|
-
# Email security scanner detection (optional): path to a MaxMind GeoLite2-ASN database
|
|
525
|
-
# ASN_DB_PATH=/data/GeoLite2-ASN.mmdb
|
|
526
|
-
|
|
527
|
-
# Mobile SDK (optional — for iOS Universal Links and Android App Links)
|
|
528
|
-
IOS_TEAM_ID=ABC123XYZ
|
|
529
|
-
IOS_BUNDLE_ID=com.yourcompany.yourapp
|
|
530
|
-
ANDROID_PACKAGE_NAME=com.yourcompany.yourapp
|
|
531
|
-
ANDROID_SHA256_FINGERPRINTS=AA:BB:CC:DD:...
|
|
532
|
-
|
|
533
|
-
# Custom domain for QR code URLs (optional)
|
|
534
|
-
SHORTLINK_DOMAIN=yourdomain.com
|
|
535
|
-
```
|
|
536
|
-
|
|
537
|
-
## Database Schema
|
|
538
|
-
|
|
539
|
-
Core does not create a `users` table. Authentication and user management are the consumer's responsibility. The `user_id` column on `links` and `webhooks` is optional (nullable, no foreign key) — use it for multi-tenant scoping when your auth layer provides a user identity.
|
|
540
|
-
|
|
541
|
-
### Short codes and URL resolution
|
|
542
|
-
|
|
543
|
-
Every short code is unique across the server. Links created through Core's API have no organization, so they all share one set of codes.
|
|
544
|
-
|
|
545
|
-
An application that builds on Core and assigns links to organizations (by setting `links.organization_id`) gets one set of codes per organization: two organizations can each have `promo`. Template slugs stay unique across the server, and URLs resolve like this:
|
|
546
|
-
|
|
547
|
-
- `/{templateSlug}/{shortCode}` resolves to exactly one link.
|
|
548
|
-
- `/{shortCode}` on a request that belongs to an organization resolves only that organization's link with that code.
|
|
549
|
-
- A request that belongs to an organization is served only that organization's links, in either form: another organization's link is answered with 404, and the refusal is logged.
|
|
550
|
-
- `/{shortCode}` on any other request prefers a link without a template, then the oldest link with that code.
|
|
551
|
-
|
|
552
|
-
To tell Core which organization a request belongs to, for example by custom domain, register a resolver:
|
|
553
|
-
|
|
554
|
-
```typescript
|
|
555
|
-
import { setLinkScopeResolver } from '@linkforty/core';
|
|
556
|
-
|
|
557
|
-
setLinkScopeResolver(async (request) => organizationIdForHost(request.headers.host) ?? null);
|
|
558
|
-
```
|
|
559
|
-
|
|
560
|
-
The redirect, SDK resolve and preview routes all use it. A resolver that throws is logged and treated as null.
|
|
561
|
-
|
|
562
|
-
### Links Table
|
|
563
|
-
|
|
564
|
-
| Column | Type | Description |
|
|
565
|
-
|-------------------------|--------------|------------------------------------------|
|
|
566
|
-
| id | UUID | Primary key |
|
|
567
|
-
| user_id | UUID | Optional owner/tenant identifier |
|
|
568
|
-
| short_code | VARCHAR(20) | Short code, unique per organization_id |
|
|
569
|
-
| original_url | TEXT | Original URL |
|
|
570
|
-
| title | VARCHAR(255) | Link title |
|
|
571
|
-
| description | TEXT | Link description |
|
|
572
|
-
| ios_app_store_url | TEXT | iOS App Store URL |
|
|
573
|
-
| android_app_store_url | TEXT | Android Play Store URL |
|
|
574
|
-
| web_fallback_url | TEXT | Web fallback URL |
|
|
575
|
-
| app_scheme | VARCHAR(255) | URI scheme (e.g., "myapp") |
|
|
576
|
-
| ios_universal_link | TEXT | iOS Universal Link URL |
|
|
577
|
-
| android_app_link | TEXT | Android App Link URL |
|
|
578
|
-
| deep_link_path | TEXT | In-app destination path |
|
|
579
|
-
| deep_link_parameters | JSONB | Custom app parameters |
|
|
580
|
-
| utm_parameters | JSONB | UTM tracking parameters |
|
|
581
|
-
| targeting_rules | JSONB | Country/device/language targeting |
|
|
582
|
-
| og_title | VARCHAR(255) | Open Graph title |
|
|
583
|
-
| og_description | TEXT | Open Graph description |
|
|
584
|
-
| og_image_url | TEXT | Open Graph image URL |
|
|
585
|
-
| og_type | VARCHAR(50) | Open Graph type (default: "website") |
|
|
586
|
-
| launchpad_mode | VARCHAR(10) | Launchpad page: inherit / on / off |
|
|
587
|
-
| attribution_window_hours| INTEGER | Install attribution window (default: 168)|
|
|
588
|
-
| attribution_window_minutes| INTEGER | Window in minutes; replaces the hours when set |
|
|
589
|
-
| one_install_per_click | BOOLEAN | A click matches one install only (default: false) |
|
|
590
|
-
| is_active | BOOLEAN | Active status |
|
|
591
|
-
| expires_at | TIMESTAMP | Expiration date |
|
|
592
|
-
| created_at | TIMESTAMP | Creation timestamp |
|
|
593
|
-
| updated_at | TIMESTAMP | Last update timestamp |
|
|
594
|
-
|
|
595
|
-
### Click Events Table
|
|
596
|
-
|
|
597
|
-
| Column | Type | Description |
|
|
598
|
-
|--------------|--------------|------------------------------|
|
|
599
|
-
| id | UUID | Primary key |
|
|
600
|
-
| link_id | UUID | Foreign key to links |
|
|
601
|
-
| clicked_at | TIMESTAMP | Click timestamp |
|
|
602
|
-
| ip_address | INET | User IP address |
|
|
603
|
-
| user_agent | TEXT | User agent string |
|
|
604
|
-
| device_type | VARCHAR(20) | Device type (ios/android/web)|
|
|
605
|
-
| platform | VARCHAR(20) | Platform (iOS/Android/Web) |
|
|
606
|
-
| country_code | CHAR(2) | Country code |
|
|
607
|
-
| country_name | VARCHAR(100) | Country name |
|
|
608
|
-
| region | VARCHAR(100) | Region/state |
|
|
609
|
-
| city | VARCHAR(100) | City |
|
|
610
|
-
| latitude | DECIMAL | Latitude |
|
|
611
|
-
| longitude | DECIMAL | Longitude |
|
|
612
|
-
| timezone | VARCHAR(100) | Timezone |
|
|
613
|
-
| utm_source | VARCHAR(255) | UTM source |
|
|
614
|
-
| utm_medium | VARCHAR(255) | UTM medium |
|
|
615
|
-
| utm_campaign | VARCHAR(255) | UTM campaign |
|
|
616
|
-
| referrer | TEXT | Referrer URL |
|
|
617
|
-
| is_bot | BOOLEAN | Automated traffic, excluded from human analytics |
|
|
618
|
-
| bot_reason | VARCHAR(16) | Why: `edge`, `method`, `ua` or `email_scanner` |
|
|
619
|
-
|
|
620
|
-
### Device Fingerprints Table
|
|
621
|
-
|
|
622
|
-
| Column | Type | Description |
|
|
623
|
-
|------------------|-------------|-------------------------------------|
|
|
624
|
-
| id | UUID | Primary key |
|
|
625
|
-
| click_id | UUID | Foreign key to click_events |
|
|
626
|
-
| fingerprint_hash | VARCHAR(64) | SHA-256 hash of fingerprint signals |
|
|
627
|
-
| ip_address | INET | IP address |
|
|
628
|
-
| user_agent | TEXT | User agent string |
|
|
629
|
-
| timezone | VARCHAR(100)| Timezone |
|
|
630
|
-
| language | VARCHAR(10) | Browser language |
|
|
631
|
-
| screen_width | INTEGER | Screen width |
|
|
632
|
-
| screen_height | INTEGER | Screen height |
|
|
633
|
-
| platform | VARCHAR(50) | Platform |
|
|
634
|
-
| platform_version | VARCHAR(50) | Platform version |
|
|
635
|
-
| created_at | TIMESTAMP | Creation timestamp |
|
|
636
|
-
|
|
637
|
-
### Install Events Table
|
|
638
|
-
|
|
639
|
-
| Column | Type | Description |
|
|
640
|
-
|-------------------------|-------------|------------------------------------|
|
|
641
|
-
| id | UUID | Primary key |
|
|
642
|
-
| link_id | UUID | Attributed link (nullable) |
|
|
643
|
-
| click_id | UUID | Attributed click (nullable) |
|
|
644
|
-
| fingerprint_hash | VARCHAR(64) | Device fingerprint hash |
|
|
645
|
-
| confidence_score | DECIMAL | Match confidence (0-100) |
|
|
646
|
-
| installed_at | TIMESTAMP | Install timestamp |
|
|
647
|
-
| first_open_at | TIMESTAMP | First app open |
|
|
648
|
-
| deep_link_retrieved | BOOLEAN | Whether deferred link was fetched |
|
|
649
|
-
| deep_link_data | JSONB | Deferred deep link data |
|
|
650
|
-
| attribution_window_hours| INTEGER | Attribution window used (default: 168) |
|
|
651
|
-
| device_id | VARCHAR(255)| Optional device identifier |
|
|
652
|
-
| created_at | TIMESTAMP | Creation timestamp |
|
|
653
|
-
|
|
654
|
-
### In-App Events Table
|
|
655
|
-
|
|
656
|
-
| Column | Type | Description |
|
|
657
|
-
|-----------------|--------------|------------------------------|
|
|
658
|
-
| id | UUID | Primary key |
|
|
659
|
-
| install_id | UUID | Foreign key to install_events|
|
|
660
|
-
| event_name | VARCHAR(255) | Event name |
|
|
661
|
-
| event_data | JSONB | Custom event properties |
|
|
662
|
-
| event_timestamp | TIMESTAMP | When the event occurred |
|
|
663
|
-
| created_at | TIMESTAMP | Creation timestamp |
|
|
664
|
-
|
|
665
|
-
### Webhooks Table
|
|
666
|
-
|
|
667
|
-
| Column | Type | Description |
|
|
668
|
-
|------------|--------------|----------------------------------|
|
|
669
|
-
| id | UUID | Primary key |
|
|
670
|
-
| user_id | UUID | Optional owner/tenant identifier |
|
|
671
|
-
| organization_id | UUID | Optional owning organization; receives events for all its links |
|
|
672
|
-
| name | VARCHAR(255) | Webhook name |
|
|
673
|
-
| url | TEXT | Delivery URL |
|
|
674
|
-
| secret | VARCHAR(255) | HMAC signing secret |
|
|
675
|
-
| events | TEXT[] | Subscribed event types |
|
|
676
|
-
| is_active | BOOLEAN | Active status |
|
|
677
|
-
| retry_count| INTEGER | Max retries (default: 3) |
|
|
678
|
-
| timeout_ms | INTEGER | Request timeout (default: 10000) |
|
|
679
|
-
| headers | JSONB | Custom HTTP headers |
|
|
680
|
-
| created_at | TIMESTAMP | Creation timestamp |
|
|
681
|
-
| updated_at | TIMESTAMP | Last update timestamp |
|
|
682
|
-
|
|
683
|
-
## Utilities
|
|
684
|
-
|
|
685
|
-
### Generate Short Code
|
|
686
|
-
|
|
687
|
-
```typescript
|
|
688
|
-
import { generateShortCode } from '@linkforty/core';
|
|
689
|
-
|
|
690
|
-
const code = generateShortCode(8); // Returns 8-character nanoid
|
|
691
|
-
```
|
|
318
|
+
## Reference
|
|
692
319
|
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
const device = detectDevice(userAgent); // Returns 'ios' | 'android' | 'web'
|
|
699
|
-
```
|
|
700
|
-
|
|
701
|
-
### Get Location from IP
|
|
702
|
-
|
|
703
|
-
```typescript
|
|
704
|
-
import { getLocationFromIP } from '@linkforty/core';
|
|
705
|
-
|
|
706
|
-
const location = getLocationFromIP('8.8.8.8');
|
|
707
|
-
// Returns: { countryCode, countryName, region, city, latitude, longitude, timezone }
|
|
708
|
-
```
|
|
709
|
-
|
|
710
|
-
### Build Redirect URL with UTM Parameters
|
|
711
|
-
|
|
712
|
-
```typescript
|
|
713
|
-
import { buildRedirectUrl } from '@linkforty/core';
|
|
714
|
-
|
|
715
|
-
const url = buildRedirectUrl('https://example.com', {
|
|
716
|
-
source: 'twitter',
|
|
717
|
-
medium: 'social',
|
|
718
|
-
campaign: 'summer-sale'
|
|
719
|
-
});
|
|
720
|
-
// Returns: https://example.com?utm_source=twitter&utm_medium=social&utm_campaign=summer-sale
|
|
721
|
-
```
|
|
722
|
-
|
|
723
|
-
## Advanced Usage
|
|
724
|
-
|
|
725
|
-
### Custom Route Registration
|
|
726
|
-
|
|
727
|
-
```typescript
|
|
728
|
-
import { createServer } from '@linkforty/core';
|
|
729
|
-
|
|
730
|
-
const server = await createServer({
|
|
731
|
-
database: { url: 'postgresql://localhost/linkforty' },
|
|
732
|
-
});
|
|
733
|
-
|
|
734
|
-
// Add custom routes
|
|
735
|
-
server.get('/custom', async (request, reply) => {
|
|
736
|
-
return { message: 'Hello World' };
|
|
737
|
-
});
|
|
738
|
-
|
|
739
|
-
await server.listen({ port: 3000 });
|
|
740
|
-
```
|
|
741
|
-
|
|
742
|
-
### Using Individual Route Handlers
|
|
743
|
-
|
|
744
|
-
```typescript
|
|
745
|
-
import Fastify from 'fastify';
|
|
746
|
-
import { initializeDatabase, redirectRoutes, linkRoutes } from '@linkforty/core';
|
|
747
|
-
|
|
748
|
-
const fastify = Fastify();
|
|
749
|
-
|
|
750
|
-
// Initialize database separately
|
|
751
|
-
await initializeDatabase({ url: 'postgresql://localhost/linkforty' });
|
|
752
|
-
|
|
753
|
-
// Register only specific routes
|
|
754
|
-
await fastify.register(redirectRoutes);
|
|
755
|
-
await fastify.register(linkRoutes);
|
|
756
|
-
|
|
757
|
-
await fastify.listen({ port: 3000 });
|
|
758
|
-
```
|
|
759
|
-
|
|
760
|
-
#### Starting while the database is busy
|
|
761
|
-
|
|
762
|
-
`initializeDatabase()` creates or updates the schema on every start. Postgres takes a table's lock *before* it evaluates `IF NOT EXISTS`, so a statement such as `ALTER TABLE links ADD COLUMN IF NOT EXISTS …` for a column that already exists would still request an exclusive lock on `links` — and if a long statement (a retention job, a large export) holds that table, the request waits, and every later query on the table, redirects included, queues behind it until the long statement ends. To keep a routine start from ever doing that, statements whose effect already exists are answered from the catalog and never sent, and you can bound the wait for the rest:
|
|
763
|
-
|
|
764
|
-
```typescript
|
|
765
|
-
await initializeDatabase({
|
|
766
|
-
url: 'postgresql://localhost/linkforty',
|
|
767
|
-
// A schema change that waits longer than this fails the start with
|
|
768
|
-
// SQLSTATE 55P03 rather than queueing the table. Retry or restart.
|
|
769
|
-
lockTimeoutMs: 5000,
|
|
770
|
-
});
|
|
771
|
-
```
|
|
772
|
-
|
|
773
|
-
The timeout applies only to the initialisation session and is reset before that connection returns to the pool. When adding schema statements of your own, use the idempotent shapes (`ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, `DROP CONSTRAINT IF EXISTS`) or a `DO $$ … IF NOT EXISTS (SELECT … FROM information_schema …)` block, never a bare `ALTER` that asks for a lock on every start.
|
|
320
|
+
- [Redirects](docs/redirects.md)
|
|
321
|
+
- [Configuration](docs/configuration.md): server options and hooks, `linkforty.config.json`, reverse proxies, environment variables
|
|
322
|
+
- [Database schema](docs/database-schema.md)
|
|
323
|
+
- [Using Core as a library](docs/library-usage.md)
|
|
324
|
+
- [Export and import](docs/export-import.md)
|
|
774
325
|
|
|
775
326
|
## Deployment
|
|
776
327
|
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
### Production Deployment (Recommended)
|
|
780
|
-
|
|
781
|
-
Deploy to managed platforms with minimal DevOps overhead:
|
|
782
|
-
|
|
783
|
-
**Fly.io (Recommended)**
|
|
784
|
-
- Global edge deployment
|
|
785
|
-
- Managed PostgreSQL and Redis
|
|
786
|
-
- Auto-scaling and SSL included
|
|
787
|
-
- Starting at ~$10-15/month
|
|
788
|
-
|
|
789
|
-
[View Fly.io deployment guide](infra/fly.io/DEPLOYMENT.md)
|
|
790
|
-
|
|
791
|
-
See [`infra/`](infra/) directory for all deployment options and platform-specific guides.
|
|
792
|
-
|
|
793
|
-
### Docker Deployment (Recommended for Self-Hosting)
|
|
794
|
-
|
|
795
|
-
**Production-ready Docker images available on Docker Hub:**
|
|
796
|
-
|
|
797
|
-
```bash
|
|
798
|
-
# One-command deployment
|
|
799
|
-
curl -O https://raw.githubusercontent.com/linkforty/core/main/docker-compose.yml
|
|
800
|
-
docker compose up -d
|
|
801
|
-
```
|
|
802
|
-
|
|
803
|
-
**Image Details:**
|
|
804
|
-
- **Registry:** `linkforty/core`
|
|
805
|
-
- **Tags:** `latest`, `v1.x.x`, `main`
|
|
806
|
-
- **Architectures:** linux/amd64, linux/arm64
|
|
807
|
-
- **Base:** Node.js 22 Alpine (minimal, secure)
|
|
808
|
-
- **Security:** Non-root user, SBOM attestations
|
|
809
|
-
|
|
810
|
-
**Version Pinning (Recommended):**
|
|
811
|
-
```yaml
|
|
812
|
-
services:
|
|
813
|
-
linkforty:
|
|
814
|
-
image: linkforty/core:v1.5.0 # Pin to specific version
|
|
815
|
-
```
|
|
816
|
-
|
|
817
|
-
See [DOCKER.md](DOCKER.md) for complete deployment guide including:
|
|
818
|
-
- Environment configuration
|
|
819
|
-
- Health checks
|
|
820
|
-
- Backup strategies
|
|
821
|
-
- Production best practices
|
|
822
|
-
|
|
823
|
-
### Manual Deployment
|
|
824
|
-
|
|
825
|
-
For custom infrastructure needs:
|
|
826
|
-
|
|
827
|
-
1. Install dependencies: `npm install @linkforty/core`
|
|
828
|
-
2. Set up PostgreSQL database (13+)
|
|
829
|
-
3. Set up Redis (optional but recommended)
|
|
830
|
-
4. Run migrations: `npm run migrate`
|
|
831
|
-
5. Start server: `node server.js`
|
|
832
|
-
|
|
833
|
-
### Other Platforms
|
|
834
|
-
|
|
835
|
-
Community-maintained templates available for:
|
|
836
|
-
- AWS (ECS/Fargate)
|
|
837
|
-
- Google Cloud Run
|
|
838
|
-
- Railway, Render, and more
|
|
839
|
-
|
|
840
|
-
See [`infra/CONTRIBUTING.md`](infra/CONTRIBUTING.md) to add support for additional platforms.
|
|
328
|
+
Core runs anywhere you can run a container with PostgreSQL 13+ (Redis is optional). See [DOCKER.md](DOCKER.md) for the image, Compose setup, health checks, backups and version pinning.
|
|
841
329
|
|
|
842
330
|
## Performance
|
|
843
331
|
|
|
@@ -949,6 +437,7 @@ LinkForty Core supports iOS Universal Links and Android App Links for seamless d
|
|
|
949
437
|
|----------|---------|---------|
|
|
950
438
|
| React Native | [`@linkforty/mobile-sdk-react-native`](https://github.com/LinkForty/mobile-sdk-react-native) | `npm install @linkforty/mobile-sdk-react-native` |
|
|
951
439
|
| Expo | [`@linkforty/mobile-sdk-expo`](https://github.com/LinkForty/mobile-sdk-expo) | `npx expo install @linkforty/mobile-sdk-expo` |
|
|
440
|
+
| Flutter | [`linkforty_flutter`](https://github.com/LinkForty/mobile-sdk-flutter) | `flutter pub add linkforty_flutter` |
|
|
952
441
|
| iOS (Swift) | [LinkFortySDK](https://github.com/LinkForty/mobile-sdk-ios) | Swift Package Manager |
|
|
953
442
|
| Android (Kotlin) | [LinkFortySDK](https://github.com/LinkForty/mobile-sdk-android) | Gradle dependency |
|
|
954
443
|
|
|
@@ -968,49 +457,6 @@ adb shell am start -a android.intent.action.VIEW \
|
|
|
968
457
|
```
|
|
969
458
|
|
|
970
459
|
|
|
971
|
-
## Export and Import
|
|
972
|
-
|
|
973
|
-
Move a server's links and analytics to another LinkForty server (self-hosted or LinkForty Cloud), or keep a backup, with a **bundle**: one gzipped file of newline-delimited JSON.
|
|
974
|
-
|
|
975
|
-
```bash
|
|
976
|
-
# Everything on this server
|
|
977
|
-
DATABASE_URL=postgres://... npx linkforty export backup.lfbundle
|
|
978
|
-
|
|
979
|
-
# One organization's data
|
|
980
|
-
DATABASE_URL=postgres://... npx linkforty export acme.lfbundle --organization <organization-id>
|
|
981
|
-
|
|
982
|
-
# Check a bundle against a database without writing anything
|
|
983
|
-
DATABASE_URL=postgres://... npx linkforty import acme.lfbundle --dry-run
|
|
984
|
-
|
|
985
|
-
# Import it; add --organization <id> to import into an existing organization
|
|
986
|
-
DATABASE_URL=postgres://... npx linkforty import acme.lfbundle
|
|
987
|
-
```
|
|
988
|
-
|
|
989
|
-
**What a bundle carries:** organizations (name and the `appConfig` and `launchpad` settings), link templates, links, click events, device fingerprints, install events, in-app events and webhooks.
|
|
990
|
-
|
|
991
|
-
**What it leaves out:**
|
|
992
|
-
- **Webhook signing secrets.** Import issues new ones and prints them, so update your receivers.
|
|
993
|
-
- **Link-safety state** (`warn_at`, `disabled_at`, `disabled_reason`, `suspended_at`), which belongs to the server that set it.
|
|
994
|
-
|
|
995
|
-
**How import works:**
|
|
996
|
-
- **Ids are kept**, so links, clicks and installs stay connected and apps keep their install ids.
|
|
997
|
-
- **Checked first.** It reports conflicts before writing anything: ids already present, short codes already used in the target organization, template slugs already taken.
|
|
998
|
-
- **All or nothing.** It writes everything in one transaction.
|
|
999
|
-
- **Tamper check.** A bundle that was cut short or edited is refused, because its checksum no longer matches.
|
|
1000
|
-
|
|
1001
|
-
**From code:** use `exportBundle`, `exportBundleToFile`, `importBundle` and `importBundleFile`, exported from the package root. `BUNDLE_TABLES` lists every column and whether a bundle carries it. A schema test fails when a column is added without a classification there.
|
|
1002
|
-
|
|
1003
|
-
## Migrate from Another Platform
|
|
1004
|
-
|
|
1005
|
-
Switching from an existing deep linking provider? LinkForty supports zero-downtime migration via custom domain DNS cutover.
|
|
1006
|
-
|
|
1007
|
-
- [Migrate from Branch.io](https://docs.linkforty.com/migrations/branch)
|
|
1008
|
-
- [Migrate from AppsFlyer OneLink](https://docs.linkforty.com/migrations/appsflyer)
|
|
1009
|
-
- [Migrate from Firebase Dynamic Links](https://docs.linkforty.com/comparisons/firebase-dynamic-links-migration) (shut down August 2025)
|
|
1010
|
-
- [Migrate from Adjust](https://docs.linkforty.com/migrations/adjust)
|
|
1011
|
-
- [Migrate from Kochava](https://docs.linkforty.com/migrations/kochava)
|
|
1012
|
-
- [Migration overview and checklist](https://docs.linkforty.com/migrations/overview)
|
|
1013
|
-
|
|
1014
460
|
## For AI Tools (llms.txt)
|
|
1015
461
|
|
|
1016
462
|
LinkForty provides machine-readable documentation for AI coding assistants (Claude, ChatGPT, Cursor, Copilot).
|
|
@@ -1034,26 +480,12 @@ Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for det
|
|
|
1034
480
|
|
|
1035
481
|
AGPL-3.0 - see [LICENSE](LICENSE) file for details.
|
|
1036
482
|
|
|
1037
|
-
##
|
|
483
|
+
## Managed hosting
|
|
1038
484
|
|
|
1039
|
-
|
|
1040
|
-
- **[@linkforty/mobile-sdk-expo](https://github.com/LinkForty/mobile-sdk-expo)** - Expo SDK
|
|
1041
|
-
- **[mobile-sdk-ios](https://github.com/LinkForty/mobile-sdk-ios)** - iOS SDK (Swift)
|
|
1042
|
-
- **[mobile-sdk-android](https://github.com/LinkForty/mobile-sdk-android)** - Android SDK (Kotlin)
|
|
1043
|
-
- **[LinkForty Cloud](https://go.linkforty.com/m0ycvnog/core-cloud)** - Hosted SaaS version with authentication, teams, billing, and dashboard
|
|
485
|
+
Prefer not to run it yourself? [LinkForty Cloud](https://go.linkforty.com/m0ycvnog/core-cloud) is the hosted version, with authentication, teams and a dashboard.
|
|
1044
486
|
|
|
1045
487
|
## Support
|
|
1046
488
|
|
|
1047
489
|
- **Documentation**: [docs.linkforty.com](https://go.linkforty.com/m0ycvnog/core-docs)
|
|
1048
490
|
- **Issues**: [GitHub Issues](https://github.com/linkforty/core/issues)
|
|
1049
491
|
- **Discussions**: [GitHub Discussions](https://github.com/linkforty/core/discussions)
|
|
1050
|
-
|
|
1051
|
-
## Built with:
|
|
1052
|
-
- [Fastify](https://www.fastify.io/) - Fast web framework
|
|
1053
|
-
- [PostgreSQL](https://www.postgresql.org/) - Powerful database
|
|
1054
|
-
- [Redis](https://redis.io/) - In-memory cache
|
|
1055
|
-
- [Zod](https://zod.dev/) - TypeScript-first schema validation
|
|
1056
|
-
- [nanoid](https://github.com/ai/nanoid) - Unique ID generation
|
|
1057
|
-
- [geoip-lite](https://github.com/geoip-lite/node-geoip) - IP geolocation
|
|
1058
|
-
- [ua-parser-js](https://github.com/faisalman/ua-parser-js) - User agent parsing
|
|
1059
|
-
- [qrcode](https://github.com/soldair/node-qrcode) - QR code generation
|
package/llms.txt
CHANGED
|
@@ -513,7 +513,7 @@ curl "http://localhost:3000/api/links/uuid/qr?format=svg" -o qr.svg
|
|
|
513
513
|
|
|
514
514
|
### Mobile SDK Endpoints (Public)
|
|
515
515
|
|
|
516
|
-
These are called by the LinkForty mobile SDKs (`@linkforty/mobile-sdk-react-native`, `@linkforty/mobile-sdk-expo`, iOS SDK, Android SDK).
|
|
516
|
+
These are called by the LinkForty mobile SDKs (`@linkforty/mobile-sdk-react-native`, `@linkforty/mobile-sdk-expo`, `linkforty_flutter`, iOS SDK, Android SDK).
|
|
517
517
|
|
|
518
518
|
#### POST /api/sdk/v1/install — Report App Install
|
|
519
519
|
|