Using self-hosted Umami for iOS app analytics

Sankra1 pts1 comments

Using self-hosted Umami for app analytics too

Using self-hosted Umami for app analytics too

Jul 14, 2026<br>| 7 min read<br>Analytics,<br>iOS,<br>App,<br>Open Source,<br>Fly,<br>Cloudflare

Since I already self-host Umami, I wanted the same experience for my apps without a heavyweight analytics SDK, an IDFA prompt, or a third party collecting data on my behalf. I created a small open source Swift package to achieve this: umami-swift. This gives me the same visitor and visit numbers as the sites, in the same dashboard.

My Umami-instance now has apps too

Every app is registered in Umami as a website and shows up in the websites list next to the actual websites, real app icon included:

An app’s overview reads exactly like a website’s, with visitors, visits, and views counted the same way:

An app’s screens report themselves as pages. In my workout tracker, Simplest Workout Trakcer, the calendar, the annual summary, the settings sheet, and the log workout sheet each show up, so the pages list shows which parts of the app people actually use:

How it was done

umami-swift is an open source Swift package with no dependencies of its own. The public API is deliberately small:

import Umami

// At launch:<br>Umami.configure(<br>websiteId: "your-website-id",<br>host: "cardgame.ios",<br>baseURL: URL(string: "https://your-umami-host")!

// Anywhere:<br>Umami.track("game_started")<br>Umami.track("level_completed", ["level": 7, "won": true])

// When the user moves to another screen:<br>Umami.screen("settings")

// Opt-out:<br>Umami.setEnabled(false)

configure takes the website id and a host string. The host is the domain you enter when adding the website in Umami; apps have no real domain, so I use a pseudo-domain like simplestworkouttracker.ios. baseURL is for me set to https://hjerpbakk-analytics.fly.dev, the Fly machine from the previous post. Anyone else running Umami points it at their own instance. Configuring also queues the launch pageview and the app_started event, so a launch shows up in Umami, Overview included, with no further code.

track sends a named event with an optional dictionary of extra data. screen sends a pageview for a named screen. Both queue to disk and send in batches, so a launch without network loses nothing, and the UI never waits for analytics.

Adding it to an app

Adding it to your own app takes a few steps:

In Xcode, choose File → Add Package Dependencies, paste https://github.com/hjerpbakk/umami-swift, set the rule to Up to Next Major Version from 1.3.0, and add the Umami product to your app target. For a Package.swift project, add .package(url: "https://github.com/hjerpbakk/umami-swift", from: "1.3.0") to dependencies and the Umami product to your target.

In your Umami dashboard, add a new website for the app. Apps have no real domain, so give it a stable pseudo-domain like myapp.ios and copy the website id it generates.

Call Umami.configure once at launch, from your app’s init (or application(_:didFinishLaunchingWithOptions:)). Pass the website id, the same domain you entered in Umami as host, and your own Umami instance as baseURL:

Umami.configure(<br>websiteId: "",<br>host: "myapp.ios",<br>baseURL: URL(string: "https://your-umami-host")!

Run the app and open its website in your Umami dashboard. The app_started event should appear within a few seconds, tagged with a visitor id, confirming events are flowing.

That’s the whole integration.

The rest of this post is the details of how it all works.

A different client for the same backend

Umami’s tracking script talks to two endpoints: /api/send for a single event and /api/batch for several at once. Both take a JSON body, and neither asks for an API key or a signed request. Umami identifies a website by the website-id in the payload, not by who sent it.

So a Swift app can call the same endpoints directly, without a browser or a script tag. It sends the same shape of JSON the tracking script would have produced, with a User-Agent that doesn’t trip Umami’s bot filter. The honeypot from the previous post applies to apps too: a request that looks like a bot gets a 200 back, and the hit never shows up in the dashboard.

Getting unique users right

Umami counts traffic in two units: visitors and visits. A visitor is an identity that persists across visits, a visit is one session. On the web, Umami computes the visitor from a cookieless hash of IP and User-Agent. An app has no browser session to hash, so umami-swift supplies the visitor id itself: every event carries a payload.id, a UUID that Umami accepts as an already-computed visitor identifier.

That UUID is random, lives only in memory, and rotates when the calendar day changes. Nothing is written to the device, so there is no id to read back and usage on different days can’t be linked. Because the id dies with the process, each launch in practice counts as a new visitor, with a day as the ceiling rather than the typical lifetime. This keeps the app on the same footing as Umami’s cookieless web tracking, where a visitor is a...

umami website swift host visitor analytics

Related Articles