Mac & mobile apps

Mac & mobile apps

Native apps have no script tag, so they report to Pulse with a single JSON POST. You get screens, events, countries, devices and funnels for your app next to your website, and you can use the same events in funnels and goals.

Set it up#

  1. Add a site in Pulse for your app. It needs a domain you control (for example example.com or app.example.com); Pulse uses it only to check that events belong to you. Nothing has to be hosted there.
  2. Copy the site id from Settings, Tracking.
  3. Send events to POST https://stats.shedi.app/api/collect with a JSON body. No cookies, no API key, no login.

The request#

FieldRequiredLimits and meaning
siteYesYour site id.
typeYespageview (a screen view) or event.
urlYesUp to 2,000 characters, http or https, with a hostname that is your site's domain or a subdomain. Use the path to name the screen: https://example.com/app/settings.
nameFor eventsUp to 100 characters.
propsNoUp to 64 keys; values are strings (200 characters at most), numbers or booleans.
titleNoUp to 300 characters.
referrerNoA URL, up to 2,000 characters. Only a different host than your site counts as a source.
screenNoUp to 20 characters, e.g. 1440x900.
languageNoUp to 20 characters, e.g. en-US.
uidNoYour app's install id: 8 to 64 characters of A-Z a-z 0-9 . _ -, such as a UUID made on first launch. See who counts as a visitor.
  • The body must be under 8 KB (413 otherwise).
  • Only utm_* and ref query parameters are kept; a #/route hash is kept, other hashes are dropped.
  • Success is 200 with {"vid":"…"}.

Who counts as a visitor#

With a uid (recommended for apps), one install is one visitor, across months, networks and app updates. Pulse hashes the id with your server's secret before storing it and does not use the IP for identity. Make it random on first launch, never derive it from the user, and make a new one when the user turns analytics off and on again.

Without one, Pulse derives the visitor from the request's IP address and User-Agent with a salt that changes every month, then discards the IP. Two consequences for apps:

  • Set a descriptive User-Agent, such as MyApp/2.1 (macOS 15). Requests with no User-Agent, or one that looks like a script or library (curl, wget, python-requests, okhttp, axios, node-fetch, Go-http-client, Java/…), or headless-browser or crawler strings, are treated as bots: Pulse answers 202 and stores nothing. Avoid words like bot, crawler, spider, validator and scanner in your app name.
  • The User-Agent is part of the identity, so changing it per release splits one person into several visitors. Keep the part before the version stable if that matters to you.

Visits (sessions) are runs of activity with no gap over 30 minutes. Country comes from the request's network location when your host or Cloudflare provides it.

Code#

import Foundation

enum Pulse {
    static let endpoint = URL(string: "https://stats.shedi.app/api/collect")!
    static let site = "YOUR_SITE_ID"
    static let host = "example.com"   // the domain you added in Pulse

    // Descriptive and stable: Pulse drops requests with no User-Agent or a library-looking one.
    static let userAgent = "MyApp/\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1") (macOS)"

    /// A custom event, e.g. Pulse.track("export_finished", props: ["format": "pdf"])
    static func track(_ name: String, screen: String = "app", props: [String: Any] = [:]) {
        var body: [String: Any] = [
            "site": site, "type": "event", "name": name,
            "url": "https://\(host)/\(screen)",
            "language": Locale.current.identifier,
        ]
        if !props.isEmpty { body["props"] = props }
        send(body)
    }

    /// A screen view, e.g. Pulse.screen("settings")
    static func screen(_ path: String, title: String? = nil) {
        var body: [String: Any] = ["site": site, "type": "pageview", "url": "https://\(host)/\(path)"]
        if let title { body["title"] = title }
        send(body)
    }

    private static func send(_ body: [String: Any]) {
        var req = URLRequest(url: endpoint)
        req.httpMethod = "POST"
        req.setValue(userAgent, forHTTPHeaderField: "User-Agent")
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.httpBody = try? JSONSerialization.data(withJSONObject: body)
        URLSession.shared.dataTask(with: req).resume()
    }
}

Call Pulse.screen("settings") when a screen appears and Pulse.track("export_finished") for actions. They show up in Realtime, Goals and Funnels like website events. Send events in the background and ignore failures: analytics should never block your app.

Limits#

  • 120 requests a minute per IP address (429 slow down), and 6,000 a minute per site.
  • Browsers also send an Origin header, which must match your domain. Native requests carry none, which is accepted. A request that does send a foreign Origin gets 400 origin does not match site.

Errors come back as {"error":"…"}. The common ones: unknown site (404, wrong site id), hostname does not match site (the URL's host is not your domain), name (an event without a name), url, props, invalid json (400).

Common issues#

I get 200 but nothing shows up
Most often the User-Agent. A 202 means Pulse dropped the request as a bot. Print the status code and use a User-Agent like the examples. Realtime shows new events within seconds.
400 hostname does not match site
The url host must be the domain on the site (or a subdomain). Fix the url, not the site.
All my users look like one visitor
Visitors are derived from IP address plus User-Agent. Many people behind one network with the same User-Agent can merge. Include the OS version in the User-Agent to separate more devices.
Can I track a user across months or devices?
No, by design. See privacy.