‹ Back to home
Docs · For developers

Sync to Your Server

Smite can keep a copy of your training on a server you run: for your own dashboard, a spreadsheet, a home-automation flow or an AI coach. This guide builds that server, from an empty folder to workouts arriving as JSON.

Protocol smite-sync v1 · Last updated October 13, 2026
At a glance
✓One POST per batch, JSON body, Authorization: Bearer <token>, HTTPS only.
✓Answer with its batchId. A 2xx echoing batchId is the only thing that counts as received.
✓Upsert means replace. Every upsert carries the whole entity, sets and all.
✓Deletes are explicit. Nothing disappears silently; a delete arrives as a change.
✓Retries are byte-identical. Acknowledge a batch you already stored again.
✓You own the data. It goes from the phone to your URL. Smite's developer never sees it.

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.

A receiver has five jobs. Each one is a few lines:

  1. 1
    Accept a POST. Smite sends to your URL exactly as entered, query string included, with a JSON body of up to about 1 MB.
  2. 2
    Check the token. Compare the Authorization header with Bearer <your token> in constant time. Answer 401 when it doesn't match, and Smite pauses and asks the user to fix the token.
  3. 3
    Check the protocol. protocol must be smite-sync and protocolVersion 1. If it isn't, answer 400 with the error code unsupported_protocol.
  4. 4
    Apply the changes. Skip a batchId you have already stored. Otherwise go through changes in order: an upsert replaces the entity, a delete removes it. Do it in one transaction.
  5. 5
    Acknowledge. Once it is stored, answer 200 with {"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:

receiver.mjsjavascript
// 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");
Keep the order: store first, then answer. If your server stores the batch but the answer gets lost on the way back, Smite sends the same batch again with the same 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:

Caddyfilecaddy
# 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:

Terminalsh
# For testing: a temporary https://….trycloudflare.com address, no domain needed.
cloudflared tunnel --url http://127.0.0.1:8787
Smite doesn't follow redirects: a 3xx 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:

Terminalsh
# 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"}
  1. 1
    Open the setting. In Smite go to Settings → Sync, Backups & Export → Sync to Your Server.
  2. 2
    Enter the server. Paste the HTTPS URL and the access token. Once saved, the token is never shown again. It can only be replaced.
  3. 3
    Test Connection. This sends an empty ping batch and no data. A green "Connected" means the token, the URL and your acknowledgement are all right.
  4. 4
    Turn 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.
  5. 5
    Start 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.
Changing the server URL counts as a new destination. Smite asks for consent again and sends the new server a full snapshot. Changing only the token carries on where it left off.
HTTP requesthttp
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
A batchjson
{
  "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
    }
  ]
}
FieldMeaning
batchIdUUID of this batch, also sent as Idempotency-Key. A retry sends the same bytes, so the same id: deduplicate on it.
sequenceStrictly increasing per installation and never reused. 0 only for ping.
createdAtWhen the batch was sealed. It doesn't change on a retry.
modeincremental, snapshot or ping (section 11).
source.installationIdOne Smite install on one device. A new one appears after "Turn Off and Forget Server", a reinstall, or a phone restored from a backup.
preferredUnitsHow the user enters numbers (kg/lb, cm/in), for showing them back. The data itself is always metric.
changesUp to 250 per batch, ordered so that an exercise arrives before the workouts that use it.
Change fieldMeaning
entityTypeWhat it is (the table below). Acknowledge batches that contain types you don't know, and skip those changes.
entityIdThe entity's UUID. Key your storage on entityType + entityId.
operationupsert (the whole entity is in data) or delete (no data).
revisionIncreases with every batch from one installation. A change replaces an earlier one with a lower revision from the same installation.
eventIdUUID of this change, unchanged on retries.
entityTypeWhat it isShared when
exerciseAn exercise in the user's libraryalways
barA barbell, with its weightGyms & equipment
gymA place they train, with its plates and machinesGyms & equipment
routineFolderA folder of routinesRoutines & schedule
routineA workout template with its planned setsRoutines & schedule
scheduleThe weekly plan (one per user)Routines & schedule
workoutA finished workout. One in progress is never sentalways
bodyMeasurementBodyweight, body fat or a tape measurementBody measurements

Most of what you'll do is with workouts. Here is one, with only its first exercise shown:

A workout's data (one exercise shown)json
{
  "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:

loadweightKg is…
totalThe whole load. On a barbell that is bar + plates, and platesKg gives the plates alone.
perSideOne dumbbell, or one side of a machine. Double it for the total; Smite's own volume does.
bodyweightExtraAdded to bodyweight. Positive is a belt or vest, negative is assistance (a band), 0 is just bodyweight.
noneNot used: a timed hold or a run.
unknownThe library exercise was deleted. Take the numbers at face value.

The acknowledgement is a 2xx whose JSON body carries the batch's own batchId:

200 OKjson
{ "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 answerWhat Smite doesThe user sees
2xx + matching batchIdDone; sends the next batchUp to date
401, 403Pauses until the token is fixed or they tap Sync NowAccess token was rejected
404, 405, 410PausesServer URL not found
408, 425, 429, 5xxRetries later, honouring Retry-AfterYour server had a problem, will retry
413Splits the batch in half and sends againNothing, unless a single change is too large
426, or code unsupported_protocolPausesYour server needs a different version of the sync format
other 4xxPauses, showing your error.messageYour server rejected the data: …
3xxPausesYour server tried to redirect
no answer, TLS errorRetries; a bad certificate pausesCan't reach your server / Secure connection failed
422 Unprocessable Contentjson
{ "error": { "code": "invalid", "message": "Unknown exercise" } }
A 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.

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.

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.

SentOnly when the user choosesNever sent
Workouts, every set and note; the exercise libraryRoutines and the plan; gyms and bars; gym locations; body measurements, including bodyweight from Apple HealthProgress photos, app settings, device identifiers

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:

heaviest-sets.mjsjavascript
// 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.

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.

smite@vojir.io

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.