Product Flows

Feature Flags

Turn features on and off per project, roll them out to a share of users, and hand your coding agent the prompt that wires them in.

Every project has its own flags. You create and switch them from the web dashboard (project, Flags) or the iOS app (project, Flags); your app reads them with the same key it sends events with. Sandbox and production are separate projects, so a flag can be on in one and off in the other.

What a flag is

Field Meaning
key What your code checks, e.g. new_onboarding. Lowercase letters, digits, _, -, ., at most 64 characters. It cannot be renamed: a new name would read as off in every build already shipped.
enabled The master switch. Off is off for everyone, picked users included, so it is also the kill switch.
rolloutPercent 0 to 100, the share of users who get it while it is on.
userIds Up to 100 users who always get it while it is on, whatever the percentage.

A project holds up to 200 flags.

How a flag is decided

For a given userId, in this order:

  1. switched off: off;
  2. the user is in userIds: on;
  3. rollout at 100%: on, also for a request with no user;
  4. no user, or rollout at 0%: off;
  5. otherwise the user is on when sha256("<key>:<userId>") falls in the first rolloutPercent of 100 buckets.

The bucket is stable: the same user always lands on the same side, and raising 10% to 30% keeps everyone who already had it. Hashing the key with the user keeps two flags at 10% from hitting the same 10% of people.

To try a feature yourself before anyone else: switch it on, set the rollout to 0% and put your own userId in the list.

Reading flags from an app

bash
curl "https://api.logga.sh/v1/flags?projectId=<project id>&userId=u_123" \
  -H "Authorization: Bearer $LOGGA_API_KEY"
# {"userId":"u_123","flags":{"new_onboarding":true,"paywall_v2":false}}

The answer carries booleans for the user you named and nothing else: never the percentages, never anybody's id. That is why a key compiled into an app can read flags safely. App keys carry the flags:read scope; keys that could already send events were given it when flags shipped.

The rules an integration should follow, which the prompt below spells out for your agent:

  • read every flag in one request at launch and when the app returns to the foreground, with a short timeout, never blocking the UI on it;
  • keep the last good answer and use it while offline. On the very first launch there is no copy yet: every flag reads off until the first read returns;
  • a missing, unknown or unreadable flag is off, and the off path is today's behaviour, so a Logga outage changes nothing for your users;
  • send the same userId you send with events. No login needed: a stable anonymous id (a random UUID kept in the Keychain) works the same. When that visitor signs up, link the anonymous id to the new user id from your backend (POST /v1/users/link): they keep the version they saw, and the goal they reach after signing up counts in their group.

Limits: 300 reads per 15 minutes per userId (per address for requests without one), and 6000 per 15 minutes per address. Phones behind the same carrier address each have their own budget. Past the limit the answer is 429: keep the cached flags.

A minimal Swift helper:

swift
import Foundation

final class Flags {
    static let shared = Flags()
    private let cacheKey = "logga.flags"
    private(set) var values: [String: Bool]

    private init() {
        values = UserDefaults.standard.dictionary(forKey: cacheKey) as? [String: Bool] ?? [:]
    }

    func isEnabled(_ key: String) -> Bool { values[key] ?? false }

    /// Call at launch and when the app returns to the foreground.
    func refresh(userId: String) async {
        var components = URLComponents(string: "https://api.logga.sh/v1/flags")!
        components.queryItems = [
            URLQueryItem(name: "projectId", value: "<project id>"),
            URLQueryItem(name: "userId", value: userId)
        ]
        var request = URLRequest(url: components.url!, timeoutInterval: 3)
        request.setValue("Bearer \(Secrets.loggaKey)", forHTTPHeaderField: "Authorization")

        struct Answer: Decodable { let flags: [String: Bool] }
        guard let (data, response) = try? await URLSession.shared.data(for: request),
              (response as? HTTPURLResponse)?.statusCode == 200,
              let answer = try? JSONDecoder().decode(Answer.self, from: data)
        else { return } // keep the last good answer

        values = answer.flags
        UserDefaults.standard.set(answer.flags, forKey: cacheKey)
    }
}

The dashboard shows when an app last read the project's flags (updated at most once a minute), which is the quickest way to know the integration works.

A/B results

A flag in a partial rollout is already an A/B test: some users get the new version, the rest the old one. Pick a goal for it (an event such as purchase or trial_started) and the flag shows, for each version, how many users got it, how many of them sent the goal afterwards, and what that means in one sentence.

  • Who is counted. The first time an app reads a flag with a userId while the rollout is between 1% and 99%, Logga records which version that user got. One record per user, flag and phase, first answer wins. Hand-picked users and reads without a userId are not recorded.
  • When only some users are in the test. If the experiment concerns, say, new installs without a purchase, counting everyone who opens the app dilutes the result (returning users and paying ones never see the difference). Read with &expose=false, which records nothing, and report the user once they are really in the audience with POST /v1/flags/<key>/exposure and body {"userId":"..."}. The server assigns the version with the same rules and answers {"key","enabled","exists","recorded"}; calling it again is harmless (recorded: false).
  • What counts as a conversion. At least one goal event with the same userId, sent after the user got the flag in the current phase, and before the phase ended. Earlier ones do not count.
  • The verdict. The probability that the new version converts better, from the two rates (normal approximation). Logga names a winner only when that probability is at least 95% (or at most 5%), with at least 50 users in each group and 10 goals in total; below that it says to keep the test running. It never shows 100%: a sample does not give certainty.
  • Changing the rollout during a test starts a new phase. A user's version depends on the share: at 50% a user in the 50-79 slice gets the old version, at 80% the new one. So a new share (or switching a split back on) opens a new phase, and the result counts only that phase, from zero: each user is recorded again on their next read, in the version they get now. Without this, a user who moved would carry their later goals into the group they left, which hides the difference. The earlier phases stay in the database but are no longer in the result.
  • Ending a test at 100% or 0%, or switching the flag off, closes the phase without resetting it: the numbers stay as they were, and goals sent after that moment are not counted, because from then on both groups see the same thing.

The goal list offers the event types the project received in the last 30 days.

The prompt for your agent

Both the web page and the iOS screen have Prompt for your agent: it builds a prompt with this project's endpoint, its id and its current flags, ready to paste into Claude Code, Cursor or Codex inside your app's repository. Each flag also has Prompt to wire this flag, which adds the task of putting that one feature behind it, using the flag's description. The text comes from the API (GET /v1/dashboard/flags/prompt), so web and iOS always hand out the same words.

Managing flags without the dashboard

Everything the dashboard does is also open to a script or an agent:

  • REST, with a key that carries flags:manage: /v1/feature-flags lists, creates, changes and deletes flags and reads their A/B results. A key bound to a project manages that project's flags only. App keys never carry this scope: the rules name users, and an app key is public.
  • MCP: list_flags, get_flag_results, create_flag, update_flag, delete_flag, with every default connector. A scheduled check of an experiment is a get_flag_results call.

API

See Feature flags in the API reference.