Deep Blue Links
Documentation menu

iOS

Open your app from its links with universal links, resolve each link with the API, and find the link that led to an install.

This guide uses Swift. Replace demo with your app's name and YOUR_API_KEY with its key: the sandbox key in debug builds and the live key in release builds.

1. Add the associated domain

In Xcode, select your app's target, open Signing & Capabilities, add Associated Domains and enter:

applinks:demo.dev.thedbl.ink

Add one entry per custom domain too, such as applinks:link.example.com. The domains must match the team ID and bundle ID in your app's settings, because iOS checks them against the apple-app-site-association file Deep Blue Links serves.

Send the URL to POST /api/links/resolve. A 404 means the URL isn't one of your short links, such as a link's full URL, so route by the URL itself.

DeepBlueLinks.swift
import Foundation

struct ResolvedLink: Decodable {
    let id: Int
    let longUrl: String
    let shortUrl: String
    let isSandbox: Bool?
}

struct DeepBlueLinksError: LocalizedError {
    let status: Int
    var errorDescription: String? { "Deep Blue Links answered \(status)" }
}

enum DeepBlueLinks {
    static let apiKey = "YOUR_API_KEY"

    static func resolve(_ url: URL) async throws -> ResolvedLink? {
        try await post("https://dev.thedbl.ink/api/links/resolve", body: ["url": url.absoluteString])
    }

    static func post<Body: Encodable>(_ endpoint: String, body: Body) async throws -> ResolvedLink? {
        var request = URLRequest(url: URL(string: endpoint)!)
        request.httpMethod = "POST"
        request.setValue(apiKey, forHTTPHeaderField: "Api-Key")
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONEncoder().encode(body)

        let (data, response) = try await URLSession.shared.data(for: request)
        let status = (response as? HTTPURLResponse)?.statusCode ?? 0
        if status == 404 { return nil }
        guard status == 200 else { throw DeepBlueLinksError(status: status) }
        return try JSONDecoder().decode(ResolvedLink.self, from: data)
    }
}

In a SwiftUI app, universal links arrive in onOpenURL:

@main
struct DemoApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { url in
                    Task {
                        let link = try? await DeepBlueLinks.resolve(url)
                        let destination = link.flatMap { URL(string: $0.longUrl) } ?? url
                        Router.shared.open(path: destination.path, query: destination.query)
                    }
                }
        }
    }
}

Router stands for your app's own navigation. When link.isSandbox == true, load the destination's content from your sandbox backend.

In a UIKit app with scenes, the link arrives as a browsing-web user activity, at launch and while the app runs:

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    if let url = connectionOptions.userActivities.first(where: { $0.activityType == NSUserActivityTypeBrowsingWeb })?.webpageURL {
        open(url)
    }
}

func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
    if userActivity.activityType == NSUserActivityTypeBrowsingWeb, let url = userActivity.webpageURL {
        open(url)
    }
}

3. Handle your URL scheme

If your app has a URL scheme, the preview page's Open the App button opens it with the link's path after the scheme, such as myapp://products/1234. That URL isn't a short link, so don't resolve it: its host is the first path segment, so route it as /products/1234. In SwiftUI it arrives in the same onOpenURL:

if url.scheme == "myapp" {
    Router.shared.open(path: "/" + (url.host ?? "") + url.path, query: url.query)
}

On the first launch after an install, ask POST /api/links/check-deferred for the link the user opened before installing. Send the IANA timezone and the screen in pixels, which is points times the screen's scale:

import UIKit

struct DeferredRequest: Encodable {
    struct Device: Encodable {
        let screenWidth: Double
        let screenHeight: Double
        let language: String
    }
    let timezone: String
    let device: Device?
}

extension DeepBlueLinks {
    @MainActor
    static func checkDeferred() async throws -> ResolvedLink? {
        let screen = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.screen
        let device = screen.map { screen in
            DeferredRequest.Device(
                screenWidth: Double(screen.bounds.width * screen.scale),
                screenHeight: Double(screen.bounds.height * screen.scale),
                language: Locale.preferredLanguages.first ?? "en")
        }
        let body = DeferredRequest(timezone: TimeZone.current.identifier, device: device)
        return try await post("https://dev.thedbl.ink/api/links/check-deferred", body: body)
    }
}

Call it once your first scene is active, and route like a resolved link when it returns one:

if !UserDefaults.standard.bool(forKey: "checkedDeferredLink") {
    UserDefaults.standard.set(true, forKey: "checkedDeferredLink")
    if let link = try? await DeepBlueLinks.checkDeferred(), let destination = URL(string: link.longUrl) {
        Router.shared.open(path: destination.path, query: destination.query)
    }
}

A 404 here is normal: most launches don't follow a link. Deferred deep links explains when a match isn't possible.

Test it

  • Universal links work on a device, not the Simulator. Paste a short link into Notes and tap it; long-press it to choose between the app and Safari.
  • Typing the link into Safari's address bar opens the page, not the app, by design.
  • In Settings › Developer › Universal Links, Diagnostics checks a URL against your installed app.
  • iOS fetches apple-app-site-association through Apple's CDN, which can lag behind a change to your app's settings. While testing, applinks:demo.dev.thedbl.ink?mode=developer with Associated Domains Development turned on fetches it directly.