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_nameis the English catalog name, the same whatever language the user's app is in, or the user's own name for a custom exercise. -
positioncounts from 0 and equals the array index;set_numbercounts 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_leftandreps_right, durations totime_left_secondsandtime_right_seconds, andrepsandtime_secondsstaynull.weight_kgis the shared weight when both sides have the same value. Otherwise it isnull, and the two values are inraw.weight_kg_leftandraw.weight_kg_right. A weight missing on one side counts as different.distance_mis set only when both sides match; differing distances are not sent.raw.recorded_atis the left side's time. -
A left or right set without its pair is sent as a normal set, with
raw.sideset to"L"or"R". -
raw.set_idsholds Steely's id for the set, or the left and right ids of a pair.raw.recorded_atis 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 ofweight_kg,reps,reps_left,reps_right, atime_*_secondsfield ordistance_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.