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#
- Add a site in Pulse for your app. It needs a domain you control (for example
example.comorapp.example.com); Pulse uses it only to check that events belong to you. Nothing has to be hosted there. - Copy the site id from Settings, Tracking.
- Send events to
POST https://stats.shedi.app/api/collectwith a JSON body. No cookies, no API key, no login.
The request#
| Field | Required | Limits and meaning |
|---|---|---|
site | Yes | Your site id. |
type | Yes | pageview (a screen view) or event. |
url | Yes | Up 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. |
name | For events | Up to 100 characters. |
props | No | Up to 64 keys; values are strings (200 characters at most), numbers or booleans. |
title | No | Up to 300 characters. |
referrer | No | A URL, up to 2,000 characters. Only a different host than your site counts as a source. |
screen | No | Up to 20 characters, e.g. 1440x900. |
language | No | Up to 20 characters, e.g. en-US. |
uid | No | Your 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_*andrefquery parameters are kept; a#/routehash is kept, other hashes are dropped. - Success is
200with{"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 answers202and 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
Originheader, which must match your domain. Native requests carry none, which is accepted. A request that does send a foreign Origin gets400 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
202means 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
urlhost must be the domain on the site (or a subdomain). Fix theurl, 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.