LinkIO (Ruby)

Self-hosted deep linking for mobile apps — an open-source alternative to Branch.io.

LinkIO is the Ruby port of LinkIO-Backend (Node.js). It gives you universal/app links, smart app-or-store redirects, fingerprint-based deferred deep linking, and referral tracking — with a framework-agnostic core, a mountable Rails engine, and Rack middleware.

CI Gem Version License: MIT


Features

  • Universal Links (iOS) — automatic apple-app-site-association generation
  • App Links (Android) — automatic assetlinks.json generation
  • Smart redirect — tries to open the installed app, then falls back to the App Store / Play Store after a configurable timeout
  • Deferred deep linking — IP-fingerprint matching preserves link data across the browser → install → app-open journey (works even when User-Agents differ)
  • Referral tracking — record and query referrer → referee relationships
  • Role-based multi-app routing — one link (?code=…&role=…) can open different apps (e.g. a User app vs a Vendor app) and fall back per role
  • Pluggable storage — in-memory, Redis, or your own adapter
  • Three ways to mount — plain Ruby, a Rails engine, or Rack middleware
  • Fully tested with RSpec, zero required runtime dependencies
  • Runs on Ruby 3.0 through 3.4 and head

Installation

# Gemfile
gem "linkio"
bundle install
# or
gem install linkio

Requires Ruby >= 3.0 (tested on 3.0–3.4 and head).


Quick start (Rails)

LinkIO ships a mountable engine. Generate the initializer:

rails generate linkio:install

Fill in your identifiers in config/initializers/linkio.rb:

LinkIO.configure do |config|
  config.domain = "yourdomain.com"

  # iOS
  config.ios_app_id = "123456789"
  config.ios_team_id = "TEAMID123"
  config.ios_bundle_id = "com.yourapp"
  config.ios_app_scheme = "yourapp"           # yourapp://

  # Android
  config.android_package_name = "com.yourapp"
  config.android_app_scheme = "yourapp"        # yourapp://
  config.android_sha256_fingerprints = ["AA:BB:CC:...:99"]

  config.fallback_timeout = 2500               # ms before falling back to the store

  # Development. Use RedisStorage in production (see below).
  config.storage = LinkIO::Storage::InMemoryStorage.new
end

Mount the engine at the domain root (so the /.well-known files resolve):

# config/routes.rb
mount LinkIO::Engine => "/"

That exposes:

Method Path Purpose
GET /.well-known/apple-app-site-association iOS universal-link verification
GET /.well-known/assetlinks.json Android app-link verification
GET /link Generic deep link handler (any query params)
GET /pending-link Retrieve deferred link by client IP fingerprint
GET /pending-link/:device_id Retrieve deferred link by device id
POST /track-referral Record a referral (referralCode, userId, metadata)
GET /referrals/:referrer_id List referrals for a referrer

Quick start (Rack / Sinatra / any Rack app)

# config.ru
require "linkio"
require "redis"

LinkIO.configure do |config|
  config.domain = "yourdomain.com"
  config.ios_app_id = "123456789"
  config.ios_team_id = "TEAMID123"
  config.ios_bundle_id = "com.yourapp"
  config.ios_app_scheme = "yourapp"
  config.android_package_name = "com.yourapp"
  config.android_app_scheme = "yourapp"
  config.android_sha256_fingerprints = ["AA:BB:CC:...:99"]
  config.storage = LinkIO::Storage::RedisStorage.new(Redis.new)
end

use LinkIO::Rack::Middleware          # uses LinkIO.client by default
run ->(_env) { [404, { "content-type" => "text/plain" }, ["Not found"]] }

The middleware handles the LinkIO routes and passes everything else through to your app. Pass an explicit client with use LinkIO::Rack::Middleware, client: my_client.


Using the core directly

The engine and middleware are thin wrappers around LinkIO::Client, which you can use anywhere:

client = LinkIO::Client.new(
  domain: "yourdomain.com",
  ios_app_id: "123456789",
  ios_team_id: "TEAMID123",
  ios_bundle_id: "com.yourapp",
  ios_app_scheme: "yourapp",
  android_package_name: "com.yourapp",
  android_app_scheme: "yourapp",
  android_sha256_fingerprints: ["AA:BB:CC:...:99"],
  storage: LinkIO::Storage::InMemoryStorage.new
)

# Handle a deep link — returns a LinkIO::Result (redirect / html / json)
request = LinkIO::Request.from_rack(env)          # or build one yourself
result  = client.handle_deep_link(request)
result.redirect?   # => true/false
result.location    # => store URL (for redirects)
result.body        # => HTML string or Ruby hash (json)

# Deferred deep linking (called by your mobile SDK after install)
client.pending_link_by_fingerprint(client_ip)     # => LinkIO::DeepLinkData | nil
client.pending_link(device_id)                     # => LinkIO::DeepLinkData | nil

# Referrals
client.track_referral("CODE123", "user-42", { campaign: "summer" })
client.referrals("CODE123")                        # => [LinkIO::ReferralData]
client.referral_for_user("user-42")                # => LinkIO::ReferralData | nil

# Verification documents
client.apple_app_site_association                  # => Hash
client.asset_links                                 # => Array<Hash>

LinkIO::Request fields: user_agent, full_url, query_params, path_params, client_ip, device_id. Build one from a Rack env with LinkIO::Request.from_rack(env), or construct it directly for full control.


Role-based routing (multiple apps)

Have more than one app — say a User app and a Vendor app — and want a single referral link to open the right one? Register an app target per role and LinkIO routes on a query/path param (default role):

LinkIO.configure do |config|
  config.domain = "rokart.in"
  config.role_param  = "role"    # which param selects the app (default: "role")
  config.default_role = "user"   # used when role is missing or unknown

  config.app "user" do |app|
    app.ios_app_id = "111111111"
    app.ios_team_id = "TEAMID123"
    app.ios_bundle_id = "com.rokart.user"
    app.ios_app_scheme = "rokartuser"
    app.android_package_name = "com.rokart.user"
    app.android_app_scheme = "rokartuser"
    app.android_sha256_fingerprints = ["AA:BB:...:99"]
    # Optional: where to send visitors when the app isn't installed (per role).
    app.fallback_url = "https://rokart.in/refer?code={code}&role=user"
  end

  config.app "vendor" do |app|
    app.ios_app_id = "222222222"
    app.ios_team_id = "TEAMID123"
    app.ios_bundle_id = "com.rokart.vendor"
    app.ios_app_scheme = "rokartvendor"
    app.android_package_name = "com.rokart.vendor"
    app.android_app_scheme = "rokartvendor"
    app.android_sha256_fingerprints = ["CC:DD:...:11"]
    app.fallback_url = "https://rokart.in/refer?code={code}&role=vendor"
  end

  config.storage = LinkIO::Storage::InMemoryStorage.new
end

Now share https://rokart.in/link?code=ABC123&role=vendor:

Visitor Behaviour
iOS/Android with the Vendor app Smart-redirect page opens rokartvendor://link?code=ABC123&role=vendor
Mobile without the app installed Falls back to that role's fallback_url (or, if none set, that role's App Store / Play Store listing)
Desktop / web Redirects to the role's fallback_url (or returns a JSON "open on mobile" message if none set)
role missing or unrecognised Uses default_role (here, user)

Fallback URL templating. fallback_url supports {param} placeholders filled (and URL-encoded) from the link's query — e.g. https://rokart.in/refer?code={code}&role=vendor becomes …?code=ABC123&role=vendor. Any query param can be interpolated, so this is fully generic — add region, campaign, etc. as needed.

Verification files. apple-app-site-association and assetlinks.json automatically include an entry for every registered app target, so both apps verify from the one domain.

The single-app (flat) configuration shown earlier is just the special case of one target — everything above still applies with role simply ignored.


Storage

Any object implementing the LinkIO::Storage::Base interface can back LinkIO.

In-memory (development)

config.storage = LinkIO::Storage::InMemoryStorage.new

Process-local and thread-safe. Data is lost on restart and not shared between workers — don't use it in production.

Redis (production)

require "redis"
config.storage = LinkIO::Storage::RedisStorage.new(Redis.new)

Keys are namespaced (linkio:pending:*, linkio:fingerprint:*, linkio:referral:*, linkio:referrer:*) identically to the Node.js backend, so both implementations can share a Redis. Pending links are written with a TTL and expire automatically.

Custom

Subclass LinkIO::Storage::Base and implement the nine methods (save_pending_link, get_pending_link, delete_pending_link, the three *_by_fingerprint variants, save_referral, get_referrals_by_referrer, get_referral_by_referee). Pending-link methods take/return LinkIO::PendingLinkData; referral methods take/return LinkIO::ReferralData. Both expose #to_h / .from_h for JSON-compatible (camelCase) serialization.


Configuration reference

Top-level options:

Option Required Default Description
domain Your linking domain
storage A LinkIO::Storage::Base implementation
role_param "role" Query/path param used to select an app target
default_role App target used when role_param is missing/unknown
fallback_timeout 2500 ms before the redirect page falls back
default_deep_link_path Reserved for future use

Per-app-target options (set directly on config for a single app, or inside config.app "role" do |app| … end for multiple):

Option Required Default Description
ios_app_id Apple numeric app id (App Store URL)
ios_team_id Apple developer Team ID (AASA appID)
ios_bundle_id iOS bundle identifier (AASA appID)
ios_app_scheme Custom scheme, e.g. yourappyourapp://
android_package_name Android package name
android_app_scheme Custom scheme for Android
android_sha256_fingerprints [] SHA-256 signing cert fingerprints for assetlinks.json
fallback_url Per-role destination when the app isn't installed / on web; supports {param} templating

If ios_app_scheme / android_app_scheme is omitted, LinkIO redirects straight to the fallback (store or fallback_url) instead of rendering the smart-redirect page.


Ecosystem

LinkIO pairs with the platform SDKs: LinkIO-iOS, LinkIO-Android, and LinkIO-React-Native, plus the Node.js backend it is ported from.

Development

bin/setup            # bundle install
bundle exec rspec    # run tests
bundle exec rubocop  # lint
bundle exec rake     # both

Contributing

Bug reports and pull requests are welcome at https://github.com/pt-nakul-sharma/linkio-rails.

License

Released under the MIT License.