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.Custom(
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 at schema_version: 1; new fields only ever arrive as must-ignore additions. Fixes ride in identity segments: each segment carries the user_id that was active when its rows were recorded, so an account switch while offline can never attribute one user’s points to another. The wire shape is normative in the sync spec.

{
"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.4.0",
"model": "iPhone15,3"
},
"extras": {
"trip": "morning-commute"
},
"segments": [
{
"user_id": "app-user-123",
"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",
"battery_percent": 87,
"is_charging": false,
"power_save_mode": false
}
],
"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 attempt. Tracing only — never dedupe on it; a retried batch may be rebuilt with a fresh batch_id. Dedupe on the row ids.
session_idstringyesOne tracking run (start()stop()), stable across relaunch. Stitch a trip with it.
sent_atstringyesWhen the device assembled the batch (fresh per attempt).
deviceobjectyesConstant per install.
extrasobjectnoYour opaque string → string map from setExtras(...). Omitted when empty. Never carries identity.
reported_stateobjectnoCloud-mode device health/config report. Never sent to custom backends’ concern — ignore it if present.
segmentsarrayyesIdentity segments in chronological order. Each is {user_id?, locations[], geofence_events[]}user_id omitted means the segment is anonymous (attribute to device.install_id). Consecutive segments never share an identity.

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

FieldTypeAlwaysMeaning
idstring (UUIDv7)yesFix id. The dedup key — 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.
battery_percentintegernoBattery level when the fix was recorded. Omitted when unknown.
is_chargingbooleannoCharging at record time. Omitted when unknown.
power_save_modebooleannoOS power saver active at record time. Omitted when unknown.
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 the row ids (INSERT … ON CONFLICT DO NOTHING) so retries are harmless — never on batch_id, which is tracing-only and may change on a retry.
  3. Store each segment’s locations and geofence events with its user_id.
  4. Answer with the right status code2xx means “I durably have it; delete your copy.” An empty 2xx body is a fully conforming response.
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-in for your database. In production use a table with a UNIQUE index
// on each row id, and INSERT ... ON CONFLICT DO NOTHING — the row id is the
// dedup key (batch_id is tracing-only and may change on a retry).
const fixes = new Map(); // id -> row
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 — dedupe on each row's id; replays are no-ops.
for (const segment of envelope.segments ?? []) {
for (const loc of segment.locations ?? []) {
if (fixes.has(loc.id)) continue;
fixes.set(loc.id, {
...loc,
sessionId: envelope.session_id,
installId: envelope.device.install_id,
userId: segment.user_id ?? null, // record-time identity
});
}
// ...store segment.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. Retry-After is honored on 429.
5xx, timeout, network errortransientKeeps the rows, retries with backoff. Retry-After is honored on 503.
413too largeKeeps the rows, halves the batch and retries, down to a single row; a single row that still gets 413 is dropped as poison so the queue can’t wedge.
404, 3xx, any other 4xxpermanentKeeps the rows. 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 rows, possibly regrouped under a fresh batch_id. Dedupe on each row id and even a partial write is safe to replay.

Your 2xx may carry a JSON body — every field optional, unknown fields ignored. This is the down-channel Beekon Cloud rides (a config_revision staleness signal, a geofence_halo slice, pending device commands); a custom backend can implement any subset or, most commonly, none. Returning a bare 200 with no body is the complete, conforming contract. A malformed body never un-accepts an upload — the rows stay deleted on the device.

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 each row id (segments[].locations[].id / segments[].geofence_events[].id) — never on batch_id.
  • Store fixes + geofence events with each segment’s user_id; session_id stitches a run, device.install_id identifies the device.
  • Return 2xx only after the data is durably stored (empty body is fine); 400/422 for permanently bad payloads; 5xx to ask for a retry.
  • Serve over HTTPS for any non-loopback URL.