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.
Features
- Universal Links (iOS) — automatic
apple-app-site-associationgeneration - App Links (Android) — automatic
assetlinks.jsongeneration - 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. yourapp → yourapp:// |
|
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.