Skip to content

Sync & your backend

Beekon stores every fix on the device. Sync is how those fixes reach a backend you run: Beekon batches the stored history, POSTs it as a small JSON envelope, and deletes the rows your server acknowledges. It’s opt-in — pass a SyncConfig with a url. Without one, nothing ever leaves the device (local-only mode).

Add a sync block to your config — the only required field is url.

Beekon.configure(
BeekonConfig.SelfManaged(
sync = SyncConfig(
url = "https://api.example.com/locations",
headers = mapOf("Authorization" to "Bearer <static-token>"), // optional
intervalSeconds = 300,
batchSize = 100,
),
),
)

url is used verbatim — Beekon adds no path. Set static credentials with headers (e.g. an API key); for rotating bearer tokens use auth instead, so the SDK refreshes them for you even in the background.

Once a SyncConfig is set, uploads are woken by a set of triggers; whichever fires first uploads, and they all feed the same batched drain:

TriggerWhen it fires
A geofence event is recordedImmediately — geofence events are sparse and high-signal
A session stops with fixes pendingImmediately — flushes the trip tail
Every intervalSeconds (floored per platform)The staleness backstop
sync() is calledImmediately
A regular fix is persistedOnly when syncThreshold: N ≥ 1 andN fixes are pending

The first four are unconditional. syncThreshold governs only the last — an early upload of regular fixes while moving; 0 (the default) means regular fixes ride the intervalSeconds schedule. A batch carries exactly one session — the oldest pending, oldest fixes first — so your server can stitch a tracking run back together.

FieldDefaultWhat it does
url(required)Your ingest endpoint, used verbatim.
headers{}Static headers on every POST. Where a fixed API key lives. (Auth’s Authorization, if set, wins over a static one.)
intervalSeconds300Scheduled upload cadence. iOS runs an in-process loop with a 60 s floor; Android background uploads run through WorkManager, whose periodic minimum is ~15 min.
batchSize100Max locations per POST (clamped 1…1000). A triggered upload still drains in batchSize chunks.
syncThreshold0Pending-fix count that triggers an early upload of regular fixes. 0 disables it.

The two time/count knobs bound different things: intervalSeconds bounds staleness; syncThreshold bounds latency while moving (you’re never more than N regular fixes behind, without the Android 15-min floor).

GoalConfig
Default — periodic batching, prompt geofence/stop flushsyncThreshold: 0
Live map / fleet trackingsyncThreshold: 10
Per-point realtimesyncThreshold: 1
Backend accepts one point per requestbatchSize: 1 (pair with a threshold)

The syncStatus stream reports upload progress and outcomes (in flight, success, auth failure, retry). Poll pendingUploadCount() for rows still waiting, and call sync() to trigger an immediate cycle.

Each upload is a single JSON object — frozen at schema_version: 1; new fields only ever arrive as must-ignore additions.

{
"schema_version": 1,
"batch_id": "0190d4e2-7b1a-7c3e-9f10-2a4b6c8d0e1f",
"session_id": "0190d4e0-1234-7000-8000-aabbccddeeff",
"sent_at": "2026-06-15T12:34:56.789Z",
"device": {
"install_id": "0190c1a4-aaaa-7bbb-8ccc-ddddeeeeffff",
"platform": "ios",
"sdk_version": "0.1.1",
"model": "iPhone15,3"
},
"extras": {
"user_id": "app-user-123",
"trip": "morning-commute"
},
"locations": [
{
"id": "0190d4e2-7b1a-7c3e-9f10-2a4b6c8d0e20",
"timestamp": "2026-06-15T12:34:10.000Z",
"trigger": "interval",
"motion": "moving",
"is_mock": false,
"latitude": 37.7749,
"longitude": -122.4194,
"accuracy": 5.2,
"speed": 2.5,
"bearing": 180,
"altitude": 14.3,
"activity": "walking"
}
],
"geofence_events": [
{
"id": "0190d4e2-7b1a-7c3e-9f10-2a4b6c8d0e21",
"geofence_id": "home",
"type": "exit",
"timestamp": "2026-06-15T12:34:05.000Z"
}
]
}
FieldTypeAlways presentMeaning
schema_versionintegeryes1 for this format. Branch on it to be future-proof.
batch_idstring (UUIDv7)yesFresh per POST. Dedupe on it — a retry carries the same batch_id.
session_idstringyesOne tracking run (start()stop()), stable across relaunch. Stitch a trip with it.
sent_atstringyesWhen the device assembled the batch.
deviceobjectyesConstant per install.
extrasobjectnoYour string → string map from setExtras(...). Omitted when empty.
locationsarrayyesThe batched fixes (may be empty).
geofence_eventsarrayyesEnter/exit events since the last batch (may be empty).

device carries install_id (UUIDv7, stable per app install), platform ("android" / "ios"), sdk_version, and model (omitted when blank).

FieldTypeAlwaysMeaning
idstring (UUIDv7)yesFix id. Dedupe on it.
timestampstringyesWhen the fix was recorded.
triggerstringyesinterval · motion · check_in · geofence · manual
motionstringyesmoving · stationary · unknown
is_mockbooleanyesOS flagged the fix as mock.
latitude, longitudenumberyesDegrees.
accuracynumbernoHorizontal accuracy, metres. Omitted when absent.
speednumbernoMetres/second. Omitted when absent.
bearingnumbernoCourse, degrees. Omitted when absent.
altitudenumbernoMetres. Omitted when absent.
activitystringnostationary · walking · running · cycling · automotive · unknown. Omitted when activity detection is off.
qualitystringnolow_accuracy · implausible_speed. Omitted when the fix is good — absence means OK.
FieldTypeMeaning
idstring (UUIDv7)Event id. Dedupe on it.
geofence_idstringThe geofence that fired (your id).
typestringenter or exit.
timestampstringWhen the crossing occurred.

You can rely on:

  • TransportPOST, Content-Type: application/json, Content-Encoding: gzip (the body is always gzipped).
  • Timestamps — ISO-8601 UTC with exactly three fractional digits and a literal Z: YYYY-MM-DDTHH:MM:SS.mmmZ.
  • Enums — always lowercase snake_case (check_in, low_accuracy).
  • Numbers — an integral value renders without a decimal (180, not 180.0).
  • Optionals are omitted, never null — an absent accuracy/speed/bearing/altitude means “the OS didn’t report it.” Treat missing as unknown, never 0.
  • Bad rows are dropped at the source — a fix with a non-finite latitude/longitude is removed before it’s sent; the rest still arrive.

Your endpoint receives the upload and answers with a status code that tells the SDK what to do with the batch. Every Beekon backend does four things:

  1. Gunzip and parse the body (Content-Encoding: gzip, JSON inside).
  2. Dedupe on batch_id (and per-fix id) so retries are harmless.
  3. Store the locations and geofence events.
  4. Answer with the right status code2xx means “I durably have it; delete your copy.”
import express from 'express';
import zlib from 'node:zlib';
const app = express();
// Beekon sends gzipped JSON. Take the raw bytes; don't let a JSON body-parser touch them.
app.use('/ingest', express.raw({ type: '*/*', limit: '12mb' }));
// Stand-ins for your database. In production use a table with a UNIQUE index
// on batch_id and on each row id, and INSERT ... ON CONFLICT DO NOTHING.
const seenBatches = new Set();
const fixes = [];
app.post('/ingest', (req, res) => {
// 1. Gunzip + parse.
let envelope;
try {
const raw = req.headers['content-encoding'] === 'gzip'
? zlib.gunzipSync(req.body)
: req.body;
envelope = JSON.parse(raw.toString('utf8'));
} catch {
return res.sendStatus(400); // unparseable — the SDK drops it and won't retry
}
// 2. (Optional) authenticate. Return 401 to make the SDK refresh its token and retry.
// if (!authorized(req)) return res.sendStatus(401);
// 3. Idempotent ingest — a retry carries the same batch_id.
if (seenBatches.has(envelope.batch_id)) return res.sendStatus(200);
seenBatches.add(envelope.batch_id);
for (const loc of envelope.locations ?? []) {
fixes.push({
...loc,
sessionId: envelope.session_id,
installId: envelope.device.install_id,
userId: envelope.extras?.user_id ?? null,
});
}
// ...store envelope.geofence_events the same way (dedupe on their id too)...
// 4. Acknowledge — only after the data is durably written.
return res.sendStatus(200);
});
app.listen(8080, () => console.log('ingest listening on :8080'));

The SDK reads your HTTP status and acts on the batch — it never deletes data it isn’t sure you have:

You returnSDK treats it asWhat the SDK does
2xxacceptedDeletes those rows. ✅
400, 422rejectedDrops the batch — won’t retry. Use only for a permanently bad payload.
401, 403authKeeps the rows, refreshes the token (if configured), retries once.
408, 429transientKeeps the rows, backs off, retries.
5xx, timeout, network errortransientKeeps the rows, retries with backoff.
413, 404, 3xx, any other 4xxpermanentKeeps the rows — no auto-shrink. You must lower batchSize or fix the endpoint.

A 2xx that never reaches the device (dropped connection, a timeout after you committed) leaves the SDK believing the upload failed — so it retries the same batch. Dedupe on batch_id and you simply re-acknowledge; dedupe on each location id and even a partial write is safe to replay.

There are two ways to authenticate uploads:

  • Static header — a fixed API key in SyncConfig.headers. Simple, but it never rotates and it’s stored in plaintext on the device.
  • Declarative refresh — an AuthConfig recipe. The SDK attaches a bearer token and refreshes it itself against your token endpoint, proactively before it expires and reactively on a 401/403. This runs headless, in the background, across a cold launch — no callback from your app code.

Use the static header for low-stakes keys; use AuthConfig for anything that should rotate.

SyncConfig(
url = "https://api.example.com/locations",
auth = AuthConfig(
accessToken = "<initial access token>", // seed, optional
refreshToken = "<initial refresh token>", // seed
expiresAt = System.currentTimeMillis() / 1000 + 3600, // epoch seconds
refreshUrl = "https://auth.example.com/oauth/token",
refreshPayload = mapOf(
"grant_type" to "refresh_token",
"refresh_token" to "{refreshToken}", // substituted at refresh time
),
// strategy = AuthStrategy.Bearer, refreshBodyFormat = AuthBodyFormat.Form,
// skewMarginSeconds = 60 — all defaults
),
)

The supplied tokens are a seed: the SDK adopts them once, then owns and rotates the live token set in encrypted storage (Keychain / Keystore). Rotations surface on the authChanges stream — mirror them into your own session store if you need to.

FieldDefaultMeaning
accessTokenSeed access token (optional).
refreshTokenSeed refresh token / long-lived credential.
expiresAtSeed expiry, epoch seconds. Null ⇒ no proactive refresh (reactive only).
strategyBearerHow the token attaches: BearerAuthorization: Bearer <token>; RawAuthorization: <token>.
refreshUrlToken endpoint. Null ⇒ seed-only, no refresh.
refreshPayload{}Request body fields. {accessToken} / {refreshToken} are substituted with the current tokens.
refreshHeadersAuthorization: Bearer {accessToken}Request headers, same templating.
refreshBodyFormatFormForm (application/x-www-form-urlencoded) or Json.
responseMappingOverride the keys Beekon reads from the response (supports dot-paths like data.access_token).
skewMarginSeconds60Refresh this many seconds before expiresAt.

When the SDK refreshes, it sends a POST to refreshUrl (15 s timeout) with the templated payload, and parses the response for the new token. Respond 2xx with JSON:

{ "access_token": "<new token>", "expires_in": 3600 }

Beekon reads, in order, responseMapping.accessTokenaccess_tokenaccessTokentoken; expiry from expiresAt / expiresIn / expires_at / expires_in (relative is now + value). A numeric string like "3600" is fine.

Refresh returnsOutcome
2xx with a tokenAdopt the new token set, retry the upload.
2xx without a tokenDead credentials — stop refreshing until you re-seed.
408, 429, 5xx, network/timeoutTransient — back off and retry.
3xx or any other 4xxDead credentials — stop refreshing until you re-seed.

After 3 consecutive refresh failures the SDK opens a circuit breaker (exponential backoff) so it never hammers your token endpoint. To recover from a dead-credential state, supply a fresh accessToken via configure().

  • Proactive — when expiresAt is set, refresh fires at expiresAt − skewMarginSeconds, before a request ever fails.
  • Reactive — a 401/403 triggers one refresh, then the upload retries once.
  • Single-flight — concurrent callers share one in-flight refresh; a burst can’t stampede your endpoint.
  • Persisted & encrypted — rotated tokens survive process death; the upload backlog is kept across a failed-auth window so nothing is lost.

With no sync block, Beekon is local-only: it tracks, persists fixes and geofence events on the device, and serves history reads and geofencing — but makes no network request at all. Nothing leaves the device.

One difference worth knowing: in local-only the 7-day age prune applies, so old fixes drop on schedule. With sync set, the full backlog is kept (bounded only by the 100,000-row cap) so an offline device can drain a long catch-up when it reconnects.

  • Accept POST at the exact url in SyncConfig (no redirect).
  • Gunzip the body, then JSON.parse.
  • Dedupe on batch_id and on each locations[].id / geofence_events[].id.
  • Store fixes + geofence events; session_id stitches a run, device.install_id identifies the device.
  • Return 2xx only after the data is durably stored; 400/422 for permanently bad payloads; 5xx to ask for a retry.
  • Serve over HTTPS for any non-loopback URL.