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).
Turn on sync
Section titled “Turn on sync”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, ), ),)try await Beekon.shared.configure( BeekonConfig.selfManaged( sync: SyncConfig( url: URL(string: "https://api.example.com/locations")!, headers: ["Authorization": "Bearer <static-token>"], // optional intervalSeconds: 300, batchSize: 100 ) ))await Beekon.instance.configure( BeekonConfig.selfManaged( sync: SyncConfig( url: 'https://api.example.com/locations', headers: {'Authorization': 'Bearer <static-token>'}, // optional intervalSeconds: 300, batchSize: 100, ), ),);await Beekon.configure({ mode: 'selfManaged', sync: { url: 'https://api.example.com/locations', headers: { Authorization: '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.
When uploads happen
Section titled “When uploads happen”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:
| Trigger | When it fires |
|---|---|
| A geofence event is recorded | Immediately — geofence events are sparse and high-signal |
| A session stops with fixes pending | Immediately — flushes the trip tail |
Every intervalSeconds (floored per platform) | The staleness backstop |
sync() is called | Immediately |
| A regular fix is persisted | Only when syncThreshold: N ≥ 1 and ≥ N 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.
SyncConfig
Section titled “SyncConfig”| Field | Default | What 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.) |
intervalSeconds | 300 | Scheduled 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. |
batchSize | 100 | Max locations per POST (clamped 1…1000). A triggered upload still drains in batchSize chunks. |
syncThreshold | 0 | Pending-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).
| Goal | Config |
|---|---|
| Default — periodic batching, prompt geofence/stop flush | syncThreshold: 0 |
| Live map / fleet tracking | syncThreshold: 10 |
| Per-point realtime | syncThreshold: 1 |
| Backend accepts one point per request | batchSize: 1 (pair with a threshold) |
Observing sync
Section titled “Observing sync”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.
The upload payload
Section titled “The upload payload”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" } ]}Root fields
Section titled “Root fields”| Field | Type | Always present | Meaning |
|---|---|---|---|
schema_version | integer | yes | 1 for this format. Branch on it to be future-proof. |
batch_id | string (UUIDv7) | yes | Fresh per POST. Dedupe on it — a retry carries the same batch_id. |
session_id | string | yes | One tracking run (start()→stop()), stable across relaunch. Stitch a trip with it. |
sent_at | string | yes | When the device assembled the batch. |
device | object | yes | Constant per install. |
extras | object | no | Your string → string map from setExtras(...). Omitted when empty. |
locations | array | yes | The batched fixes (may be empty). |
geofence_events | array | yes | Enter/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).
locations[]
Section titled “locations[]”| Field | Type | Always | Meaning |
|---|---|---|---|
id | string (UUIDv7) | yes | Fix id. Dedupe on it. |
timestamp | string | yes | When the fix was recorded. |
trigger | string | yes | interval · motion · check_in · geofence · manual |
motion | string | yes | moving · stationary · unknown |
is_mock | boolean | yes | OS flagged the fix as mock. |
latitude, longitude | number | yes | Degrees. |
accuracy | number | no | Horizontal accuracy, metres. Omitted when absent. |
speed | number | no | Metres/second. Omitted when absent. |
bearing | number | no | Course, degrees. Omitted when absent. |
altitude | number | no | Metres. Omitted when absent. |
activity | string | no | stationary · walking · running · cycling · automotive · unknown. Omitted when activity detection is off. |
quality | string | no | low_accuracy · implausible_speed. Omitted when the fix is good — absence means OK. |
geofence_events[]
Section titled “geofence_events[]”| Field | Type | Meaning |
|---|---|---|
id | string (UUIDv7) | Event id. Dedupe on it. |
geofence_id | string | The geofence that fired (your id). |
type | string | enter or exit. |
timestamp | string | When the crossing occurred. |
Encoding rules
Section titled “Encoding rules”You can rely on:
- Transport —
POST,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, not180.0). - Optionals are omitted, never null — an absent
accuracy/speed/bearing/altitudemeans “the OS didn’t report it.” Treat missing as unknown, never0. - Bad rows are dropped at the source — a fix with a non-finite
latitude/longitudeis removed before it’s sent; the rest still arrive.
Build your ingest endpoint
Section titled “Build your ingest endpoint”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:
- Gunzip and parse the body (
Content-Encoding: gzip, JSON inside). - Dedupe on
batch_id(and per-fixid) so retries are harmless. - Store the locations and geofence events.
- Answer with the right status code —
2xxmeans “I durably have it; delete your copy.”
A complete receiver
Section titled “A complete receiver”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'));What your status code means
Section titled “What your status code means”The SDK reads your HTTP status and acts on the batch — it never deletes data it isn’t sure you have:
| You return | SDK treats it as | What the SDK does |
|---|---|---|
| 2xx | accepted | Deletes those rows. ✅ |
| 400, 422 | rejected | Drops the batch — won’t retry. Use only for a permanently bad payload. |
| 401, 403 | auth | Keeps the rows, refreshes the token (if configured), retries once. |
| 408, 429 | transient | Keeps the rows, backs off, retries. |
| 5xx, timeout, network error | transient | Keeps the rows, retries with backoff. |
| 413, 404, 3xx, any other 4xx | permanent | Keeps 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.
Auth & token refresh
Section titled “Auth & token refresh”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
AuthConfigrecipe. The SDK attaches a bearer token and refreshes it itself against your token endpoint, proactively before it expires and reactively on a401/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.
Configure declarative refresh
Section titled “Configure declarative refresh”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 ),)SyncConfig( url: URL(string: "https://api.example.com/locations")!, auth: AuthConfig( accessToken: "<initial access token>", // seed, optional refreshToken: "<initial refresh token>", // seed expiresAt: Date().timeIntervalSince1970 + 3600, // epoch seconds refreshUrl: URL(string: "https://auth.example.com/oauth/token")!, refreshPayload: [ "grant_type": "refresh_token", "refresh_token": "{refreshToken}", // substituted at refresh time ] // strategy: .bearer, refreshBodyFormat: .form, skewMarginSeconds: 60 — defaults ))SyncConfig( url: 'https://api.example.com/locations', auth: AuthConfig( accessToken: '<initial access token>', // seed, optional refreshToken: '<initial refresh token>', // seed expiresAt: DateTime.now().millisecondsSinceEpoch / 1000 + 3600, // epoch seconds refreshUrl: 'https://auth.example.com/oauth/token', refreshPayload: { 'grant_type': 'refresh_token', 'refresh_token': '{refreshToken}', // substituted at refresh time }, // strategy: AuthStrategy.bearer, refreshBodyFormat: AuthBodyFormat.form, // skewMarginSeconds: 60 — defaults ),){ url: 'https://api.example.com/locations', auth: { accessToken: '<initial access token>', // seed, optional refreshToken: '<initial refresh token>', // seed expiresAt: Math.floor(Date.now() / 1000) + 3600, // epoch seconds refreshUrl: 'https://auth.example.com/oauth/token', refreshPayload: { grant_type: 'refresh_token', refresh_token: '{refreshToken}', // substituted at refresh time }, // strategy: 'bearer', refreshBodyFormat: 'form', skewMarginSeconds: 60 — 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.
The recipe
Section titled “The recipe”| Field | Default | Meaning |
|---|---|---|
accessToken | — | Seed access token (optional). |
refreshToken | — | Seed refresh token / long-lived credential. |
expiresAt | — | Seed expiry, epoch seconds. Null ⇒ no proactive refresh (reactive only). |
strategy | Bearer | How the token attaches: Bearer → Authorization: Bearer <token>; Raw → Authorization: <token>. |
refreshUrl | — | Token endpoint. Null ⇒ seed-only, no refresh. |
refreshPayload | {} | Request body fields. {accessToken} / {refreshToken} are substituted with the current tokens. |
refreshHeaders | Authorization: Bearer {accessToken} | Request headers, same templating. |
refreshBodyFormat | Form | Form (application/x-www-form-urlencoded) or Json. |
responseMapping | — | Override the keys Beekon reads from the response (supports dot-paths like data.access_token). |
skewMarginSeconds | 60 | Refresh this many seconds before expiresAt. |
Your refresh endpoint
Section titled “Your refresh endpoint”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.accessToken → access_token → accessToken → token; expiry from expiresAt / expiresIn / expires_at / expires_in (relative is now + value). A numeric string like "3600" is fine.
| Refresh returns | Outcome |
|---|---|
2xx with a token | Adopt the new token set, retry the upload. |
2xx without a token | Dead credentials — stop refreshing until you re-seed. |
408, 429, 5xx, network/timeout | Transient — back off and retry. |
3xx or any other 4xx | Dead 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().
How it behaves
Section titled “How it behaves”- Proactive — when
expiresAtis set, refresh fires atexpiresAt − skewMarginSeconds, before a request ever fails. - Reactive — a
401/403triggers 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.
Local-only mode
Section titled “Local-only mode”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.
Backend checklist
Section titled “Backend checklist”- Accept
POSTat the exacturlinSyncConfig(no redirect). - Gunzip the body, then
JSON.parse. - Dedupe on
batch_idand on eachlocations[].id/geofence_events[].id. - Store fixes + geofence events;
session_idstitches a run,device.install_ididentifies the device. - Return
2xxonly after the data is durably stored;400/422for permanently bad payloads;5xxto ask for a retry. - Serve over HTTPS for any non-loopback URL.