GymNext Flex Timer — Developer Guide
Developer Guide

Flex Timer integration: creating timers from your app

Available in Flex Timer 6.3.0 and later.

Your app or AI agent can send a timer to Flex Timer on iPhone, iPad, and Android. Flex Timer either opens it ready to adjust and start, or starts it right away. Either way, it shows on the user's connected Flex Timer displays.

For example, a training app that already knows the current exercise can start its countdown on the Flex Timer on the gym wall, without the user setting up the timer in Flex Timer.

  • One format on every platform: a timer is a small JSON object, described below and in the JSON Schema flextimer-timer.schema.json.
  • Three ways to send it: a flextimer://timer link (iOS and Android), an Android intent, or the Create Timer from JSON action in the iOS Shortcuts app.
  • The user stays in control: before your app can start timers, Flex Timer asks the user once to allow it.

Requirements

  • Flex Timer 6.3.0 or later on iOS 15+ or Android. The Shortcuts action needs iOS 16+.
  • Available to every Flex Timer user. No account or API key is needed on your side.
  • The user must be signed in to Flex Timer (or using it as a guest).

Quick start

A strength app starts a back squat timer: 5 sets, with 2 minutes of rest after each.

{
  "version": 1,
  "name": "Back Squat 5x5",
  "segments": [
    { "timerMode": "RestDriven", "name": "Back Squat", "numberOfSets": 5, "restType": "fixed", "fixedRestDuration": 120 }
  ]
}

Encode the JSON as base64url and open this link:

flextimer://timer?data=<base64url JSON>&start=1&source=My%20Gym%20App&replace=1

The first time, Flex Timer asks the user whether My Gym App may start timers. Once they allow it, your timers start immediately. replace=1 closes the timer your app started previously, so moving on to the next exercise doesn't leave the old timer open.

Sending a timer

let json = try JSONSerialization.data(withJSONObject: timer)
let data = json.base64EncodedString()
    .replacingOccurrences(of: "+", with: "-")
    .replacingOccurrences(of: "/", with: "_")
    .replacingOccurrences(of: "=", with: "")
var link = URLComponents(string: "flextimer://timer")!
link.queryItems = [
    URLQueryItem(name: "data", value: data),
    URLQueryItem(name: "start", value: "1"),
    URLQueryItem(name: "source", value: "My Gym App"),
    URLQueryItem(name: "replace", value: "1"),
    URLQueryItem(name: "x-success", value: "mygymapp://flextimer-done"),
]
UIApplication.shared.open(link.url!)

To check that Flex Timer is installed with canOpenURL(_:), add flextimer to LSApplicationQueriesSchemes in your Info.plist.

iOS: Shortcuts

The Shortcuts app includes a Create Timer from JSON action with these fields:

  • Timer JSON: the timer.
  • Start Right Away: same as start.
  • App Name: same as source.
  • Replace This App's Earlier Timers: same as replace.

Use it in automations, or from Siri, Apple Intelligence, and AI agents that run shortcuts. The action finishes after the timer has been created, so the next action in a shortcut runs afterward.

Android: send an intent

val intent = Intent("com.gymnext.timer.action.CREATE_TIMER").apply {
    setPackage("com.gymnext.timer")
    putExtra("json", timerJson)
    putExtra("start", true)
    putExtra("source", "My Gym App")
    putExtra("replace", true)
}
startActivity(intent)
  • Extras: json, data, or url, plus source, x-success, and x-error. start and replace can be booleans or the strings "1" / "true".
  • Checking that Flex Timer is installed: on Android 11 and later, first add <package android:name="com.gymnext.timer" /> to <queries> in your manifest.

The flextimer://timer?... link from the iOS example also works on Android, with Intent.ACTION_VIEW.

Encoding base64url

// JavaScript
const data = btoa(unescape(encodeURIComponent(JSON.stringify(timer))))
  .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
# Python
data = base64.urlsafe_b64encode(json.dumps(timer).encode()).decode().rstrip("=")

Options

The same options work in links, the Android intent, and the Shortcuts action, except that the callbacks are available only in links and the intent.

Option Description
data The timer JSON, base64url-encoded. Padding is optional. Up to 64 KB of JSON.
json The timer JSON as plain text. Android intent and Shortcuts only; links use data.
url An https link that returns the timer JSON. Up to 1 MB, downloaded within 10 seconds.
start 1 or true starts the timer and shows it running. Without it, Flex Timer opens the timer's settings so the user can adjust and start it.
source Your app's name. Flex Timer shows it when asking the user to allow your app. Use the same name every time.
replace 1 or true closes timers your source created earlier, before creating this one.
x-success A link Flex Timer opens after creating the timer, with timerId added to its query.
x-error A link Flex Timer opens if it can't create the timer, with errorMessage added to its query.

Give exactly one of data, json, or url. Timers are small, a few KB even for a long multi-segment workout, so data fits almost every case. Use url when the JSON is very large or your server already hosts it.

The timer format (version 1)

Field Type Description
version integer 1. Optional, but recommended.
name string Optional. The workout's name. A single-segment timer with no segment name uses this.
description string Optional. A short description of the workout.
segments array Required. 1 to 50 segments, run in order. Two or more make a multi-segment timer.

Segments

Each segment has a timerMode and that mode's fields.

  • Units: all times are whole seconds.
  • Defaults: a field you leave out takes the default shown.
  • Unknown fields are ignored.
timerMode Use it for Fields: default, then allowed range
CountUp For time timeCap: 0 (no cap), 0 to 86400
CountDown AMRAP, or any fixed length duration: 600, 1 to 86400
Rounds Equal rounds, with optional equal rest roundDuration: 60, 1 to 86400. numberOfRounds: 5, 1 to 1000. restBetweenRounds: 0, 0 to 86400
Tabata Work and rest repeated, optionally in sets workDuration: 20. restDuration: 10 (0 allowed). roundsPerTabata: 8. numberOfTabatas: 1. restBetweenTabatas: 0
ContinuousCue EMOM: a cue at every interval cueInterval: 60. totalDuration: 600
MixedInterval Any sequence of work and rest periods intervals (required, see below). numberOfRounds: 1, times the sequence repeats. restBetweenRounds: 0
RestDriven Sets of any length, each followed by rest (for example lifting) numberOfSets: 5, 1 to 1000. restType: fixed (default) or ratio. With fixed: fixedRestDuration, 120. With ratio: workRatio and restRatio, 1 each, 1 to 100
Warmup Warm-up timeCap: 600 (0 for no cap)
Cooldown Cool-down timeCap: 600 (0 for no cap)

Unless a range is shown, durations allow 1 to 86400 and counts allow 1 to 1000.

Each MixedInterval interval is an object:

  • duration: required.
  • type: work (the default) or rest.
  • name: optional.

A segment can have 1 to 200 intervals.

Fields for every segment

Field Description
name Shown on the timer. Defaults to a name for the mode, such as "Tabata" or "AMRAP".
description A short description.
details The full workout text for the athlete. Line breaks are kept.
direction true counts up, false counts down. CountUp, ContinuousCue, RestDriven, Warmup, and Cooldown count up by default; CountDown, Rounds, Tabata, and MixedInterval count down.
roundNames, intervalNames One name per round or interval, such as the exercise. Use null for unnamed ones.
roundNamesUseSame, intervalNamesUseSame true uses the first name for every round or interval.
roundIntervalNamePresentation How names are presented: none (shown as titles), cue (on screen), speech (spoken), or cueAndSpeech.

Warnings, sounds, and similar preferences come from the user's own Flex Timer settings.

Examples

A 20-minute AMRAP:

{ "version": 1, "segments": [{ "timerMode": "CountDown", "name": "AMRAP 20", "duration": 1200 }] }

A circuit, three times through:

{
  "version": 1,
  "segments": [{
    "timerMode": "MixedInterval",
    "name": "Circuit",
    "intervals": [
      { "type": "work", "duration": 30, "name": "Push-ups" },
      { "type": "rest", "duration": 10 },
      { "type": "work", "duration": 45, "name": "Squats" },
      { "type": "rest", "duration": 15 }
    ],
    "numberOfRounds": 3,
    "restBetweenRounds": 60
  }]
}

A whole session as one multi-segment timer:

{
  "version": 1,
  "name": "Saturday WOD",
  "segments": [
    { "timerMode": "Warmup", "timeCap": 600 },
    { "timerMode": "Tabata", "name": "Tabata Burpees" },
    { "timerMode": "Cooldown", "timeCap": 300 }
  ]
}

What the user sees

  • Without start: Flex Timer opens the new timer's settings, ready for the user to adjust and start.
  • With start, the first time your app sends a timer: Flex Timer asks the user whether your app may start timers.
  • Allow: this timer and later ones from your source start right away.
  • Just Once: this timer starts, and Flex Timer asks again next time.
  • Don't Allow: the timer opens in its settings without starting.
  • With start but no source: Flex Timer asks every time.
  • Changing their mind: users can remove your app under Settings > Connected Apps. Flex Timer then asks again the next time your app starts a timer.
  • When Flex Timer isn't running: your timer opens it.

Errors

If Flex Timer can't create the timer, it tells the user and opens your x-error link with an errorMessage that names the problem. For example:

  • The timer isn't valid JSON.
  • segments is required: a list of one or more timer segments.
  • segments[0].timerMode "Sprint" isn't supported. Use one of CountUp, CountDown, Rounds, Tabata, ContinuousCue, MixedInterval, RestDriven, Warmup, Cooldown.
  • segments[1].workDuration must be a whole number from 1 to 86400.
  • segments[0].intervals[2].type must be "work" or "rest".
  • This timer uses format version 2, but this version of Flex Timer reads version 1. Update Flex Timer to open it.
  • The timer is larger than 64 KB. Host the JSON and pass url instead.
  • url must be an https link.
  • Downloading the timer from url failed with HTTP status 404.

To catch these before sending, validate your JSON against flextimer-timer.schema.json.

Using it from an AI agent

  1. Give the model the JSON Schema, or the format tables above, and ask for a single JSON object that matches it. Ask for name and details too, so the athlete sees the workout on the timer.
  2. Validate the result against the schema.
  3. Send it with the Shortcuts action (iOS), the intent (Android), or a flextimer://timer link.

Versioning

This is version 1 of the format.

  • Additions: new optional fields may be added to version 1. Versions of Flex Timer that don't know them ignore them.
  • Breaking changes: a change that older versions couldn't read correctly gets a new version number.

JSON Schema

Validate your timer JSON against this schema (JSON Schema draft 2020-12) before sending it, or give it to an AI model so it produces valid timers.

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://gymnext.com/flextimer/flextimer-timer.schema.json",
  "title": "Flex Timer timer",
  "description": "A timer that another app or an AI agent sends to Flex Timer (format version 1). See timer-integration.md. All durations are whole seconds.",
  "type": "object",
  "required": ["segments"],
  "properties": {
    "version": {
      "description": "Format version. Optional; defaults to 1. Flex Timer rejects versions newer than it understands.",
      "type": "integer",
      "minimum": 1,
      "maximum": 1
    },
    "name": {
      "description": "Name of the workout. For a single-segment timer without its own name, this becomes the timer's name.",
      "type": "string"
    },
    "description": {
      "description": "Short description of the workout.",
      "type": "string"
    },
    "segments": {
      "description": "One or more timer segments, run in order. More than one makes a multi-segment timer.",
      "type": "array",
      "minItems": 1,
      "maxItems": 50,
      "items": { "$ref": "#/$defs/segment" }
    }
  },
  "$defs": {
    "seconds": { "type": "integer", "minimum": 1, "maximum": 86400 },
    "secondsOrZero": { "type": "integer", "minimum": 0, "maximum": 86400 },
    "count": { "type": "integer", "minimum": 1, "maximum": 1000 },
    "names": {
      "description": "One name per round, interval, or set; null for unnamed.",
      "type": "array",
      "items": { "type": ["string", "null"] }
    },
    "common": {
      "type": "object",
      "properties": {
        "name": { "description": "Segment name, shown on the timer. Defaults by mode (for example \"Tabata\").", "type": "string" },
        "description": { "description": "Short description of the segment.", "type": "string" },
        "details": { "description": "Full workout text for the athlete; line breaks are kept.", "type": "string" },
        "direction": {
          "description": "true counts up, false counts down. Defaults by mode: CountUp, ContinuousCue, RestDriven, Warmup, and Cooldown count up; CountDown, Rounds, Tabata, and MixedInterval count down.",
          "type": "boolean"
        },
        "roundNames": { "$ref": "#/$defs/names" },
        "intervalNames": { "$ref": "#/$defs/names" },
        "roundNamesUseSame": { "description": "Use the first round name for every round.", "type": "boolean" },
        "intervalNamesUseSame": { "description": "Use the first interval name for every interval.", "type": "boolean" },
        "roundIntervalNamePresentation": {
          "description": "How names are presented: none (titles only), cue (on screen), speech (spoken), or cueAndSpeech.",
          "enum": ["none", "cue", "speech", "cueAndSpeech"]
        }
      }
    },
    "segment": {
      "type": "object",
      "required": ["timerMode"],
      "allOf": [{ "$ref": "#/$defs/common" }],
      "oneOf": [
        {
          "title": "CountUp (For Time)",
          "properties": {
            "timerMode": { "const": "CountUp" },
            "timeCap": { "$ref": "#/$defs/secondsOrZero", "description": "Time cap; 0 for none. Default 0." }
          }
        },
        {
          "title": "CountDown (AMRAP)",
          "properties": {
            "timerMode": { "const": "CountDown" },
            "duration": { "$ref": "#/$defs/seconds", "description": "Length. Default 600." }
          }
        },
        {
          "title": "Rounds (equal rounds, optional equal rest)",
          "properties": {
            "timerMode": { "const": "Rounds" },
            "roundDuration": { "$ref": "#/$defs/seconds", "description": "Default 60." },
            "numberOfRounds": { "$ref": "#/$defs/count", "description": "Default 5." },
            "restBetweenRounds": { "$ref": "#/$defs/secondsOrZero", "description": "Default 0." }
          }
        },
        {
          "title": "Tabata (work / rest repeated, optionally in sets)",
          "properties": {
            "timerMode": { "const": "Tabata" },
            "workDuration": { "$ref": "#/$defs/seconds", "description": "Default 20." },
            "restDuration": { "$ref": "#/$defs/secondsOrZero", "description": "Default 10." },
            "roundsPerTabata": { "$ref": "#/$defs/count", "description": "Default 8." },
            "numberOfTabatas": { "$ref": "#/$defs/count", "description": "Default 1." },
            "restBetweenTabatas": { "$ref": "#/$defs/secondsOrZero", "description": "Default 0." }
          }
        },
        {
          "title": "ContinuousCue (EMOM: a cue every interval)",
          "properties": {
            "timerMode": { "const": "ContinuousCue" },
            "cueInterval": { "$ref": "#/$defs/seconds", "description": "Seconds between cues. Default 60." },
            "totalDuration": { "$ref": "#/$defs/seconds", "description": "Whole length. Default 600." }
          }
        },
        {
          "title": "MixedInterval (any sequence of work and rest)",
          "required": ["intervals"],
          "properties": {
            "timerMode": { "const": "MixedInterval" },
            "intervals": {
              "type": "array",
              "minItems": 1,
              "maxItems": 200,
              "items": {
                "type": "object",
                "required": ["duration"],
                "properties": {
                  "type": { "enum": ["work", "rest"], "description": "Default work." },
                  "duration": { "$ref": "#/$defs/seconds" },
                  "name": { "type": ["string", "null"], "description": "For example the exercise." }
                }
              }
            },
            "numberOfRounds": { "$ref": "#/$defs/count", "description": "Times the sequence repeats. Default 1." },
            "restBetweenRounds": { "$ref": "#/$defs/secondsOrZero", "description": "Default 0." }
          }
        },
        {
          "title": "RestDriven (sets of any length, each followed by rest)",
          "properties": {
            "timerMode": { "const": "RestDriven" },
            "numberOfSets": { "$ref": "#/$defs/count", "description": "Default 5." },
            "restType": { "enum": ["fixed", "ratio"], "description": "Default fixed." },
            "fixedRestDuration": { "$ref": "#/$defs/secondsOrZero", "description": "Rest after each set when restType is fixed. Default 120." },
            "workRatio": { "type": "integer", "minimum": 1, "maximum": 100, "description": "With restRatio, rest as a ratio of the set's time when restType is ratio. Default 1." },
            "restRatio": { "type": "integer", "minimum": 1, "maximum": 100, "description": "Default 1." }
          }
        },
        {
          "title": "Warmup",
          "properties": {
            "timerMode": { "const": "Warmup" },
            "timeCap": { "$ref": "#/$defs/secondsOrZero", "description": "0 for none. Default 600." }
          }
        },
        {
          "title": "Cooldown",
          "properties": {
            "timerMode": { "const": "Cooldown" },
            "timeCap": { "$ref": "#/$defs/secondsOrZero", "description": "0 for none. Default 600." }
          }
        }
      ]
    }
  }
}