Skip to content
Steely A workout diary for iPhone
← Back to Steely

Connected apps

Last updated 22 October 2026

On this page

  1. 1. What Steely sends
  2. 2. The connect link
  3. 3. Consent and the token
  4. 4. Returning to your app
  5. 5. Delivery
  6. 6. Session format
  7. 7. Updates and idempotency
  8. 8. Disconnect
  9. 9. Privacy

Connected apps lets a Steely user send the workouts they log in Steely to another app they use. Your app asks to connect by opening a link that carries its name, an HTTPS endpoint and a bearer token. Steely shows the user what will be sent and where. Once the user allows it, Steely posts each logged workout to your endpoint as JSON and sends it again whenever the user edits it. Requests go straight from the user's iPhone to your endpoint: Steely has no server in between, and there is nothing to register with us. This page describes contract version 1.

1. What Steely sends

Steely sends these, and sends them again after every later edit:

  • finished gym workouts recorded in Steely's workout logger, with their exercises and sets;
  • activities the user logged by hand, such as a run or a surf session, once they have a duration.

Steely never sends:

  • anything imported from Apple Health or any other import;
  • workouts still in progress, or deleted workouts and activities;
  • activities saved without a duration, for example during Steely's first-run setup, until the user adds one;
  • deletions. Version 1 has no deletion messages: a workout the user deletes in Steely stays in your app.

When the user connects your app, Steely sends every eligible workout and activity that already exists, oldest first, and then keeps sending changes. Delivery runs only while Steely is open in the foreground (see Delivery). Opening your return URL right after the user taps Allow moves Steely to the background, so the first upload usually completes the next time the user opens Steely. Tell your users so in your app.

2. The connect link

To connect, your app opens this URL on the user's iPhone. The line breaks are only for readability. Percent-encode every value.

steely://integrations/connect?v=1
  &name=<display name>
  &endpoint=<HTTPS URL that accepts sessions>
  &token=<opaque bearer credential>
  [&revoke=<HTTPS URL Steely calls on Disconnect>]
  [&return=<URL Steely opens after the user decides>]
Parameter Rules
v Required. Must be 1.
name Required. Your app's name as the user should see it. 1–40 characters after leading and trailing spaces are trimmed, with no control characters.
endpoint Required. The URL Steely posts sessions to. See the URL rules below.
token Required. An opaque credential Steely sends as a bearer token. 1–512 characters from A–Z a–z 0–9 - _ . ~ only, so it is always safe in an Authorization header.
revoke Optional. The URL Steely calls when the user disconnects your app. Same URL rules as endpoint.
return Optional. The URL Steely opens after the user decides. See Returning to your app.

URL rules for endpoint and revoke

  • The scheme is https.
  • The host is plain ASCII: letters, digits, dots and hyphens, or a bracketed IPv6 address. Internationalised and lookalike host names are rejected, because the confirmation sheet shows the host exactly as written.
  • The URL carries no user name or password.

Validation

Each parameter may appear only once: a repeated parameter makes the link invalid, and so does a malformed percent escape. A + in a value is read as a space. Steely checks the whole link before showing anything. An invalid link is ignored silently: no screen appears, nothing is stored and return is not opened.

3. Consent and the token

Steely then shows a confirmation sheet with your name, the host of your endpoint and what will be sent. Allow stores the connection. Not now, or dismissing the sheet, stores nothing. The sheet appears only for a user who has finished setting up Steely and has access to it. Otherwise Steely opens normally, stores nothing and does not open return.

Steely stores the token in the iOS Keychain, readable after the device's first unlock and only on that device. It never writes the token to its database, logs, analytics or diagnostics, and it does not record the link. If the user restores a backup onto another iPhone, the token does not come with it: the connection shows Needs reconnect until your app opens the connect link again.

Opening the link again with the same endpoint replaces the existing connection, and the sheet says so. Endpoints are compared after normalising the letter case of the scheme and host, a default port, a fragment and a trailing slash on an empty path. Steely revokes the old token through the old revoke URL, if there was one and the token changed, and sends everything again with the new token. The new token may belong to a different account in your app. Because you upsert sessions (see Updates), that is safe.

Another app on the device can register the same URL scheme, so the connect link is not a private channel. Issue a new token for every connection, let it do nothing except deliver sessions for that one user, and invalidate it when Steely calls revoke.

4. Returning to your app

If return is present, parses as a URL and does not point back to Steely, Steely opens it once the user has decided, with one parameter added to its query:

Value Meaning
result=allowed The user allowed the connection and Steely stored it.
result=denied The user tapped Not now or dismissed the sheet. Nothing was stored.
result=error The user allowed the connection, but Steely could not save it. Any previous connection to the same endpoint is unchanged.

Steely keeps your existing query and fragment and appends nothing else. For example, yourapp://steely/connected?state=abc opens as yourapp://steely/connected?state=abc&result=allowed. A return URL that uses one of Steely's own schemes, such as steely, is ignored.

5. Delivery

Steely sends each session in its own request:

POST <endpoint>
Authorization: Bearer <token>
Content-Type: application/json
X-Steely-Contract: 1

{ "sessions": [ <Session> ] }

The sessions array always holds exactly one session. Requests go one at a time, oldest first. Any 2xx status means the session was accepted, and Steely ignores the response body. Answer directly rather than redirecting: Steely does not follow redirects, and a 3xx response is treated like a network error.

Your response What Steely does
2xx Marks the session as delivered in the version it just sent.
401 or 403 Marks the connection Needs reconnect and stops sending to it. Sending resumes after the user reconnects. Send now in Profile makes one attempt.
3xx, 408, 429, 5xx, a network error, or no response within 15 seconds Stops this run for your connection. Everything pending is retried on the next trigger.
Any other 4xx Marks that session as failed and keeps the status and a short excerpt of your response body on the device. Steely sends it again only after the user edits it or taps Send now.

When Steely sends

  • about 3 seconds after the user's last change to a workout, its exercises or sets, or a hand-logged activity;
  • when Steely comes to the foreground;
  • right after the user connects your app;
  • when the user taps Send now in Profile. Send now also retries failed sessions.

Delivery runs only while Steely is in the foreground; there is no background task. If the token is missing from the Keychain, the connection shows Needs reconnect.

6. Session format

A session is one gym workout or one activity. The shape is a common denominator of workout services: one object per session, with exercises only for gym workouts. Both the X-Steely-Contract header and metadata.steely_contract carry the contract version.

Gym workout

{
  "source": "steely",
  "external_id": "steely:6f0c2a8e-3b1d-4c47-9a5e-2d8b7f1e4c90",
  "workout_type": "strength",
  "started_at": "2026-10-06T18:05:00+03:00",
  "ended_at": "2026-10-06T19:12:40+03:00",
  "duration_seconds": 4060,
  "distance_km": null,
  "total_energy_kcal": null,
  "avg_heart_rate": null,
  "max_heart_rate": null,
  "notes": null,
  "metadata": {
    "steely_contract": 1,
    "steely_kind": "workout",
    "steely_sport": "gym",
    "steely_name": "Push day",
    "steely_exercises": [
      { "position": 0, "exercise_id": "bench-press-barbell" }
    ]
  },
  "exercises": [
    {
      "exercise_name": "Bench Press (Barbell)",
      "position": 0,
      "sets_data": [
        {
          "set_number": 1,
          "weight_kg": 60,
          "reps": 10,
          "reps_left": null,
          "reps_right": null,
          "time_seconds": null,
          "time_left_seconds": null,
          "time_right_seconds": null,
          "distance_m": null,
          "raw": {
            "set_ids": ["a1d7c3e2-5f4b-4e8a-9c0d-7b6e5f4a3c21"],
            "side": null,
            "weight_kg_left": null,
            "weight_kg_right": null,
            "recorded_at": "2026-10-06T18:07:12+03:00"
          }
        }
      ]
    }
  ]
}

Activity

{
  "source": "steely",
  "external_id": "steely:0b9e4d17-8c2a-4f53-b6e1-93a7d5c2f804",
  "workout_type": "run",
  "started_at": "2026-10-07T07:30:00+03:00",
  "ended_at": "2026-10-07T08:15:00+03:00",
  "duration_seconds": 2700,
  "distance_km": 8.4,
  "total_energy_kcal": null,
  "avg_heart_rate": null,
  "max_heart_rate": null,
  "notes": "Easy pace along the river",
  "metadata": {
    "steely_contract": 1,
    "steely_kind": "activity",
    "steely_sport": "running"
  }
}

Fields

Field Meaning
source Always "steely".
external_id steely: followed by Steely's id for the workout or activity. Stable for its lifetime.
workout_type One of strength, run, cycling, swim, skate, surf, other. See Workout types.
started_at, ended_at ISO 8601 in whole seconds, with the UTC offset the session was recorded in. ended_at is never before started_at.
duration_seconds An integer, at least 1.
distance_km Activities only. null when there is no distance, never 0.
total_energy_kcal, avg_heart_rate, max_heart_rate Always null in version 1.
notes The activity's note, or null. Always null for gym workouts.
metadata Steely-specific details, at most 4096 bytes as UTF-8 JSON. See Metadata.
exercises Gym workouts only, and possibly empty. Activities have no exercises field, even when their type is strength.

Times and duration

Times are floored to whole seconds. For a gym workout, ended_at is the finish time, but never earlier than started_at even if the device clock jumped backwards during the workout. Its duration_seconds is the difference, at least 1. For an activity, duration_seconds is the duration the user entered, at least 60 seconds in Steely, and ended_at is started_at plus that duration. It never lies in the future.

Workout types

In Steely workout_type
Gym workout strength
Activity: gym strength
Activity: running run
Activity: cycling cycling
Activity: swimming swim
Activity: skating skate
Activity: surfing surf
Activity: other other

The list is closed in version 1. A sport Steely adds later is sent as other until contract version 2, while metadata.steely_sport carries Steely's own sport id.

Metadata

  • steely_contract: 1.
  • steely_kind: "workout" or "activity".
  • steely_sport: Steely's sport id for an activity, "gym" for a workout.
  • steely_name: the workout's name. Absent for activities.
  • steely_exercises: workouts only. The catalog or custom exercise id at each position.

If metadata would exceed 4096 bytes, Steely first drops entries from the end of steely_exercises, then drops steely_name.

Exercises and sets

  • exercise_name is the English catalog name, the same whatever language the user's app is in, or the user's own name for a custom exercise.
  • position counts from 0 and equals the array index; set_number counts from 1. Both have no gaps.
  • Some exercises are logged per side. A left and a right set at the same position become one entry: reps go to reps_left and reps_right, durations to time_left_seconds and time_right_seconds, and reps and time_seconds stay null. weight_kg is the shared weight when both sides have the same value. Otherwise it is null, and the two values are in raw.weight_kg_left and raw.weight_kg_right. A weight missing on one side counts as different. distance_m is set only when both sides match; differing distances are not sent. raw.recorded_at is the left side's time.
  • A left or right set without its pair is sent as a normal set, with raw.side set to "L" or "R".
  • raw.set_ids holds Steely's id for the set, or the left and right ids of a pair. raw.recorded_at is when the set was recorded, or created if it has no recorded time.
  • A set with no weight, reps, duration or distance is left out. An exercise left with no sets is left out. A workout left with no exercises is still sent, with "exercises": []. Positions and set numbers are renumbered after these omissions, so every set carries at least one of weight_kg, reps, reps_left, reps_right, a time_*_seconds field or distance_m.

7. Updates and idempotency

Treat (source, external_id) as the idempotency key: create the session the first time you see the key and update it afterwards. On every update, replace the session's exercise list with the one you received. Steely always sends the full current list, which can be empty.

Steely sends a session again whenever its content changes: an edit to a gym workout or to any of its exercises or sets, including a removed set, a rename of a custom exercise it uses, or an edit to an activity. Steely compares the current version with the one it last delivered and sends again whenever they differ. Device clocks can move backwards, so do not drop a request because its times look older: apply each request as the latest state of that session. Steely waits for each response, or the 15-second timeout, before sending the next request.

The same session can also arrive twice with the same content, for example when your server saved it but the response was lost. An upsert makes that harmless.

8. Disconnect

The Connected apps section appears in Steely's Profile once the user has connected at least one app. To disconnect, the user opens Profile → Connected apps → your app → Disconnect. Steely stops delivering to your app, removes the connection and its token from the device and, if you provided revoke, sends:

POST <revoke>
Authorization: Bearer <token>
X-Steely-Contract: 1

The request has no body. Steely tries once, with a 15-second timeout, and ignores the result: the connection is gone from the device either way. A delivery request that was already in flight may still arrive. Erase all data in Steely disconnects every app the same way. Deleting Steely from the iPhone does not call revoke, so give your users a way to disconnect from your side too.

Invalidate the token when you receive this call. Steely sends no deletions: the sessions you already received stay in your app unless you remove them.

9. Privacy

Steely sends sessions only to the endpoint the user approved on the confirmation sheet, directly from the user's iPhone. Steely's developer does not receive or store a copy. The token stays in the iOS Keychain on that device and never appears in Steely's database, logs, analytics or diagnostics. Your app's handling of the data it receives is governed by your own privacy policy. Steely's own practices are described in its Privacy Policy.

© 2026 Steely

Privacy PolicyTerms & ConditionsConnected appsContact support