Authorization: Bearer <token>, HTTPS only.2xx echoing batchId is the only thing that counts as received.When it's switched on, Smite watches its own database. Every time something you chose to share changes (a finished workout, an edited set, a new bodyweight, a deleted routine), it packs the change into a batch and sends it to your URL. Your server stores it and answers with a short acknowledgement. Until that answer arrives, Smite keeps the batch and tries again.
- One-way. Smite pushes and never reads anything back except the acknowledgement.
- Whole entities. A workout arrives with every exercise and set inside it. Each later change sends the whole workout again, so you never patch anything.
- Eventually, not instantly. While Smite is open, changes leave within seconds. Otherwise they leave when iOS grants background time, and at the latest the next time Smite is opened. Nothing about it runs on a timer.
- Per device, opt-in. It is off until the user turns it on, enters your URL and token, and confirms what is shared. It is part of Smite Unleashed.
- A machine that can run a small HTTP service: a home server, a NAS, a $5 VPS or a serverless function.
- An HTTPS address for it. Smite refuses plain
http://, even on your local network (section 04). - A long random access token, for example from
openssl rand -hex 32. Smite stores it in the iPhone's Keychain and sends it with every request. - Somewhere to keep JSON: SQLite, Postgres with
jsonb, files on disk. Anything works. The example below uses Node.js and its built-in SQLite.
A receiver has five jobs. Each one is a few lines:
- 1Accept a POST. Smite sends to your URL exactly as entered, query string included, with a JSON body of up to about 1 MB.
- 2Check the token. Compare the
Authorizationheader withBearer <your token>in constant time. Answer401when it doesn't match, and Smite pauses and asks the user to fix the token. - 3Check the protocol.
protocolmust besmite-syncandprotocolVersion1. If it isn't, answer400with the error codeunsupported_protocol. - 4Apply the changes. Skip a
batchIdyou have already stored. Otherwise go throughchangesin order: anupsertreplaces the entity, adeleteremoves it. Do it in one transaction. - 5Acknowledge. Once it is stored, answer
200with{"batchId": "<the same id>"}. Only then does Smite consider the batch delivered.
Here is all of it in one file of JavaScript. It needs Node.js 22.13 or newer, for the built-in node:sqlite, and nothing from npm:
// A complete Smite receiver: Node.js 22.13+, no dependencies, one SQLite file.
// SMITE_TOKEN="$(openssl rand -hex 32)" node receiver.mjs
import { createServer } from "node:http";
import { timingSafeEqual } from "node:crypto";
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync("smite.sqlite");
db.exec(`
CREATE TABLE IF NOT EXISTS batches (batch_id TEXT PRIMARY KEY);
CREATE TABLE IF NOT EXISTS entities (
entity_type TEXT, entity_id TEXT, installation_id TEXT,
revision INTEGER, deleted INTEGER, data TEXT,
PRIMARY KEY (entity_type, entity_id));
`);
const seen = db.prepare("SELECT 1 FROM batches WHERE batch_id = ?");
const current = db.prepare(
"SELECT installation_id, revision FROM entities WHERE entity_type = ? AND entity_id = ?");
const upsert = db.prepare("INSERT OR REPLACE INTO entities VALUES (?, ?, ?, ?, 0, ?)");
const remove = db.prepare("UPDATE entities SET deleted = 1, data = NULL, revision = ?,"
+ " installation_id = ? WHERE entity_type = ? AND entity_id = ?");
const stored = db.prepare("INSERT INTO batches VALUES (?)");
function apply(batch) {
if (batch.mode === "ping") return; // Test Connection: nothing to store
if (seen.get(batch.batchId)) return; // a retry: stored already, so just acknowledge it again
const source = batch.source.installationId;
for (const change of batch.changes) {
const { entityType: type, entityId: id, revision } = change;
const row = current.get(type, id);
if (row && row.installation_id === source && row.revision >= revision) {
continue; // older than what this device already sent
}
if (change.operation === "upsert") { // the whole entity: replace, never merge
upsert.run(type, id, source, revision, JSON.stringify(change.data));
} else if (change.operation === "delete") {
remove.run(revision, source, type, id);
}
}
stored.run(batch.batchId);
}
function authorized(header = "") {
const presented = Buffer.from(header);
const expected = Buffer.from(`Bearer ${process.env.SMITE_TOKEN}`);
return presented.length === expected.length && timingSafeEqual(presented, expected);
}
function answer(res, status, body) {
res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
}
createServer(async (req, res) => {
if (!authorized(req.headers.authorization)) {
return answer(res, 401, { error: { code: "unauthorized", message: "Wrong access token." } });
}
let batch;
try {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
batch = JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
return answer(res, 400, { error: { code: "invalid", message: "The body is not JSON." } });
}
if (batch.protocol !== "smite-sync" || batch.protocolVersion !== 1) {
return answer(res, 400, { error: { code: "unsupported_protocol" } });
}
db.exec("BEGIN"); // one transaction: all of the batch is stored, or none of it
try {
apply(batch);
db.exec("COMMIT");
} catch (error) {
db.exec("ROLLBACK");
console.error(error);
return answer(res, 500, { error: { code: "server", message: "Couldn't store the batch." } });
}
answer(res, 200, { batchId: batch.batchId, status: "accepted" });
}).listen(8787, "127.0.0.1");batchId. Answer it again, and it is settled.The receiver above listens on 127.0.0.1:8787 without TLS. Put something in front of it that terminates HTTPS. On a server with a domain, Caddy is the shortest route:
# Caddy fetches and renews the certificate on its own.
smite.example.com {
reverse_proxy 127.0.0.1:8787
}For a first test from your phone without a domain, a temporary Cloudflare tunnel gives you an HTTPS address in seconds:
# For testing: a temporary https://….trycloudflare.com address, no domain needed.
cloudflared tunnel --url http://127.0.0.1:87873xx stops syncing with a message. Following one would either drop the Authorization header or send it to an address the user never entered. Give Smite the final URL, not one that redirects to it.You can check the whole chain before touching the app. This is exactly what the app's Test Connection button sends:
# What Test Connection sends. A working receiver answers with the same batchId.
curl -sS https://smite.example.com/ \
-H "Authorization: Bearer $SMITE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"protocol":"smite-sync","protocolVersion":1,
"batchId":"00000000-0000-4000-8000-000000000000","sequence":0,
"createdAt":"2026-10-13T10:00:00.000Z","mode":"ping",
"source":{"app":"smite","appVersion":"1.4 (61)",
"installationId":"00000000-0000-4000-8000-000000000001"},
"preferredUnits":{"weight":"kg","length":"cm"},"changes":[]}'
# {"batchId": "00000000-0000-4000-8000-000000000000", "status": "accepted"}- 1Open the setting. In Smite go to Settings → Sync, Backups & Export → Sync to Your Server.
- 2Enter the server. Paste the HTTPS URL and the access token. Once saved, the token is never shown again. It can only be replaced.
- 3Test Connection. This sends an empty
pingbatch and no data. A green "Connected" means the token, the URL and your acknowledgement are all right. - 4Turn On… and choose what's shared. Workouts and the exercise library are always shared. Routines and the plan, gyms and bars, gym locations and body measurements are each the user's choice. Locations and body measurements start off.
- 5Start Syncing. Smite sends everything shared as a snapshot, then keeps your server up to date. The settings page shows the last successful sync and anything waiting.
POST /smite/ingest HTTP/1.1
Host: smite.example.com
Authorization: Bearer <your access token>
Content-Type: application/json; charset=utf-8
Accept: application/json
Idempotency-Key: BB000000-0000-4000-8000-000000000001
X-Smite-Protocol-Version: 1
User-Agent: Smite/1.4 (61) DataSync/1{
"protocol": "smite-sync",
"protocolVersion": 1,
"batchId": "BB000000-0000-4000-8000-000000000001",
"sequence": 42,
"createdAt": "2026-10-13T10:00:00.000Z",
"mode": "incremental",
"source": {
"app": "smite",
"appVersion": "1.4 (61)",
"installationId": "1D000000-0000-4000-8000-000000000001"
},
"preferredUnits": { "weight": "kg", "length": "cm" },
"changes": [
{
"eventId": "EE000000-0000-4000-8000-000000000001",
"entityType": "bodyMeasurement",
"entityId": "3E000000-0000-4000-8000-000000000001",
"operation": "upsert",
"revision": 42,
"data": {
"id": "3E000000-0000-4000-8000-000000000001",
"kind": "bodyweight",
"value": 82.4,
"unit": "kg",
"date": "2026-10-12T07:00:00.000Z",
"source": "appleHealth",
"note": "",
"createdAt": "2026-10-12T07:01:00.000Z"
}
},
{
"eventId": "EE000000-0000-4000-8000-000000000002",
"entityType": "workout",
"entityId": "0D000000-0000-4000-8000-000000000002",
"operation": "delete",
"revision": 42
}
]
}| Field | Meaning |
|---|---|
batchId | UUID of this batch, also sent as Idempotency-Key. A retry sends the same bytes, so the same id: deduplicate on it. |
sequence | Strictly increasing per installation and never reused. 0 only for ping. |
createdAt | When the batch was sealed. It doesn't change on a retry. |
mode | incremental, snapshot or ping (section 11). |
source.installationId | One Smite install on one device. A new one appears after "Turn Off and Forget Server", a reinstall, or a phone restored from a backup. |
preferredUnits | How the user enters numbers (kg/lb, cm/in), for showing them back. The data itself is always metric. |
changes | Up to 250 per batch, ordered so that an exercise arrives before the workouts that use it. |
| Change field | Meaning |
|---|---|
entityType | What it is (the table below). Acknowledge batches that contain types you don't know, and skip those changes. |
entityId | The entity's UUID. Key your storage on entityType + entityId. |
operation | upsert (the whole entity is in data) or delete (no data). |
revision | Increases with every batch from one installation. A change replaces an earlier one with a lower revision from the same installation. |
eventId | UUID of this change, unchanged on retries. |
| entityType | What it is | Shared when |
|---|---|---|
exercise | An exercise in the user's library | always |
bar | A barbell, with its weight | Gyms & equipment |
gym | A place they train, with its plates and machines | Gyms & equipment |
routineFolder | A folder of routines | Routines & schedule |
routine | A workout template with its planned sets | Routines & schedule |
schedule | The weekly plan (one per user) | Routines & schedule |
workout | A finished workout. One in progress is never sent | always |
bodyMeasurement | Bodyweight, body fat or a tape measurement | Body measurements |
- Units are fixed: kg, km, cm, seconds and percent. Timestamps are UTC with milliseconds, like
2026-10-13T17:05:00.000Z. - Optional fields are left out, never `null`. A missing field means "no value".
- Enumerations are plain strings. New values can appear within v1. Store them as they are.
Most of what you'll do is with workouts. Here is one, with only its first exercise shown:
{
"id": "0D000000-0000-4000-8000-000000000001",
"name": "Push A",
"category": "strength",
"startedAt": "2026-10-12T17:00:00.000Z",
"endedAt": "2026-10-12T18:05:00.000Z",
"durationSeconds": 3900,
"note": "Felt strong",
"routineId": "AA000000-0000-4000-8000-000000000001",
"gym": { "id": "6A000000-0000-4000-8000-000000000001", "name": "Home Gym" },
"recordedOnWatch": false,
"effort": { "score": 7, "manual": false },
"appearance": { "color": "#FF9F0A", "symbol": "bolt.fill" },
"exercises": [
{
"id": "AE000000-0000-4000-8000-000000000001",
"position": 0,
"exerciseId": "E1000000-0000-4000-8000-000000000001",
"exercise": { "name": "Bench Press", "kind": "weightReps", "equipment": "barbell", "muscleGroup": "chest" },
"load": "total",
"unilateral": false,
"bar": { "id": "BA000000-0000-4000-8000-000000000001", "name": "Olympic bar", "kind": "straight", "weightKg": 20, "recorded": true },
"notes": "Elbows tucked",
"restSeconds": 180,
"restAfterSeconds": 0,
"sets": [
{ "id": "5E000000-0000-4000-8000-000000000001", "position": 0, "type": "warmup",
"completed": true, "completedAt": "2026-10-12T17:05:00.000Z",
"weightKg": 60, "platesKg": 40, "reps": 8, "note": "", "restSeconds": 120 },
{ "id": "5E000000-0000-4000-8000-000000000002", "position": 1, "type": "normal",
"completed": true, "completedAt": "2026-10-12T17:10:00.000Z",
"weightKg": 100, "platesKg": 80, "reps": 5, "rpe": 8, "note": "Grindy",
"restSeconds": 180, "plannedRestSeconds": 180, "actualRestSeconds": 150 },
{ "id": "5E000000-0000-4000-8000-000000000003", "position": 2, "type": "normal",
"completed": false,
"weightKg": 100, "platesKg": 80, "reps": 3, "note": "", "restSeconds": 180 }
]
}
],
"totals": { "volumeKg": 500, "completedSets": 2 }
}The field that needs care is load. It says what weightKg on that exercise's sets means:
| load | weightKg is… |
|---|---|
total | The whole load. On a barbell that is bar + plates, and platesKg gives the plates alone. |
perSide | One dumbbell, or one side of a machine. Double it for the total; Smite's own volume does. |
bodyweightExtra | Added to bodyweight. Positive is a belt or vest, negative is assistance (a band), 0 is just bodyweight. |
none | Not used: a timed hold or a run. |
unknown | The library exercise was deleted. Take the numbers at face value. |
- `completed` says whether the set was ticked off. A finished workout can hold sets that never were. Keep them and decide yourself whether they count.
- `type` is
normal,warmup,droporfailure. - `bar` is on barbell exercises.
recorded: trueis the bar the user picked that day.recorded: falseis the standard bar Smite assumes (20 kg), with no id. - `rpe` is 1–10. Someone who logs RIR in the app is stored as
10 − RIR. - `supersetGroup`: exercises with the same number were done as a superset.
- `totals` is Smite's own volume and set count, informational only.
The acknowledgement is a 2xx whose JSON body carries the batch's own batchId:
{ "batchId": "BB000000-0000-4000-8000-000000000001", "status": "accepted" }A bare 200, an HTML page, or an acknowledgement for another batch does not count. Smite retries it. That way a URL that answers everything with 200 can't swallow the user's data. Anything else you answer has a defined effect:
| Your answer | What Smite does | The user sees |
|---|---|---|
2xx + matching batchId | Done; sends the next batch | Up to date |
401, 403 | Pauses until the token is fixed or they tap Sync Now | Access token was rejected |
404, 405, 410 | Pauses | Server URL not found |
408, 425, 429, 5xx | Retries later, honouring Retry-After | Your server had a problem, will retry |
413 | Splits the batch in half and sends again | Nothing, unless a single change is too large |
426, or code unsupported_protocol | Pauses | Your server needs a different version of the sync format |
other 4xx | Pauses, showing your error.message | Your server rejected the data: … |
3xx | Pauses | Your server tried to redirect |
| no answer, TLS error | Retries; a bad certificate pauses | Can't reach your server / Secure connection failed |
{ "error": { "code": "invalid", "message": "Unknown exercise" } }4xx stops the user's sync until they act. Don't use one for data you merely don't understand: store it or skip it, and acknowledge.- One batch at a time per installation. The next one isn't even built until this one is acknowledged, so batches arrive in
sequenceorder. - A failed batch is retried byte for byte: the same
batchId, the sameeventIds. - Retries wait 30 seconds, then twice as long each time up to 6 hours, give or take 20 %. A
Retry-Afterheader (seconds or a date) replaces that wait, between 1 second and 24 hours. - Several edits to one entity between batches arrive as one upsert of its latest state.
- `incremental` batches carry what changed since the last one.
- `snapshot` batches carry everything shared, in numbered parts (
snapshot.id,part,isFinal). They are sent on the first sync, for a new server URL, when the user taps Resend Everything, and when your acknowledgement includes"requestFullSync": true. Use that last one after you lost your database. Snapshot changes are ordinary upserts. - `ping` has
sequence: 0and no changes. It is Test Connection: check the token, acknowledge it, store nothing. - About once a week, and after the user restores a backup, Smite compares everything with what it last sent and sends only the differences, as ordinary
incrementalchanges.
The final part of a snapshot also carries snapshot.inventory: for each type being shared, every id the user has now. A receiver that may have missed deletes can remove what it holds from this installation of a listed type that isn't in the list. Never prune a type that isn't listed.
- A delete is a change with
operation: "delete"and theentityId. Deleting something you don't have is a no-op. Acknowledge it. - Something deleted can come back (an undo, a restored backup). It arrives as an upsert with a higher revision.
- Mass deletes wait for the user. When more than 25 items of one type (or a tenth of them) would go at once, Smite asks first. That usually follows restoring an older backup. If the user keeps your copy, those deletes are never sent.
- Turning a category off stops sending it. It never deletes anything on your server. The same goes for Turn Off, Turn Off and Forget Server, and Delete All Data in the app. What your server has, it keeps.
Identity
- Workouts, routines, folders, measurements, gyms and bars keep their UUID for life.
- Built-in exercises get a different id on each device until iCloud brings the devices together. Match built-ins by name if you need to.
- When Smite merges duplicates (the same custom exercise created on two devices), the losing id gets a delete, and every workout and routine that pointed at it is sent again pointing at the survivor.
- The ids of a workout's exercises and sets are informational and may be renumbered by an import. Store the workout as one document, or replace its children wholesale on every upsert.
routineId,gymId,folderIdandexerciseIdare soft links. They can name something you never received or that was deleted.
The setting is per device. If an iPhone and an iPad both have it on, you receive both, under two installationIds, about the same workouts whenever iCloud keeps them in step. Key on entityType + entityId, order changes from one installation by revision, and let the last one received win between installations: both keep sending until they match iCloud, so your copy converges too. The simplest setup is to turn it on on one device.
Privacy
| Sent | Only when the user chooses | Never sent |
|---|---|---|
| Workouts, every set and note; the exercise library | Routines and the plan; gyms and bars; gym locations; body measurements, including bodyweight from Apple Health | Progress photos, app settings, device identifiers |
- The consent screen names your server's address. A different address asks again.
- If a future version of Smite shares more, it asks again before sending any of it.
- The data goes from the phone straight to your URL. The token stays in the device's Keychain and is never logged.
- If you run a receiver for other people, you hold their health-adjacent data. Treat it that way, and tell them so.
With the receiver from section 03 every workout is a JSON document in SQLite, so a few lines of JavaScript and ordinary SQL get you a long way:
// Every completed working set of the last 30 days, heaviest first.
import { DatabaseSync } from "node:sqlite";
const db = new DatabaseSync("smite.sqlite", { readOnly: true });
const sets = db.prepare(`
SELECT json_extract(w.data, '$.startedAt') AS day,
json_extract(e.value, '$.exercise.name') AS lift,
json_extract(s.value, '$.weightKg') AS kg,
json_extract(s.value, '$.reps') AS reps
FROM entities w,
json_each(w.data, '$.exercises') e,
json_each(e.value, '$.sets') s
WHERE w.entity_type = 'workout' AND NOT w.deleted
AND json_extract(s.value, '$.completed')
AND json_extract(s.value, '$.type') <> 'warmup'
AND json_extract(w.data, '$.startedAt') >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-30 days')
ORDER BY kg DESC
`).all();
console.table(sets);From there it's whatever you want: a Grafana panel, a weekly email, a spreadsheet sync, or feeding your history to an AI model as context.
- Within protocol v1, Smite only adds: new optional fields, new entity types, new enumeration values. Nothing is renamed or removed, and nothing changes meaning.
- A receiver that ignores unknown fields, stores unknown values and acknowledges unknown entity types keeps working with every v1 release.
- A breaking change would be
protocolVersion: 2, documented here. Answer426to a version you don't speak, and the user is told.
Building something with it?
Questions about the format, a receiver that doesn't behave, or something you'd like Smite to send: write, and the developer answers.
Sync to Your Server is part of Smite Unleashed and sends data only to the server the user enters. The developer of Smite never receives it. This page describes protocol smite-sync version 1.