HTTP upload & authorization
BGeo uploads locations natively — a durable, SQLite-backed queue (bgeo.db)
drains to your server with none of your code in the hot path, and survives app
kill/reboot/backoff. Your onLocation handler is only a UI observer; nothing
downstream of persistence waits on it. This guide covers the upload side —
for how a fix gets into the queue in the first place, see the
Data pipeline concept.
Configuring the endpoint
At minimum, point url at your
ingest endpoint:
try await BackgroundGeolocation.ready( Config( url: "https://your-server.example/locations", method: "POST", // "POST" (default) | "PUT" | "PATCH" headers: ["X-Api-Key": "…"], autoSync: true // default true ))Leaving url unset (or null, which
is the same thing for a nullable Config field) puts the SDK in
persist-only mode: fixes still queue in SQLite and still reach your listeners via
onLocation, but nothing uploads until a url is set (or you call
sync() yourself).
Default body: a single record
The default request body wraps one queued record under the
httpRootProperty key,
"location":
{ "location": { "uuid": "3f9c9e2a-8b41-4b8a-9e0a-6b7d1c9a2f10", "timestamp": "2026-07-24T09:14:32.118Z", "odometer": 18420.5, "coords": { "latitude": 52.2297, "longitude": 21.0122, "accuracy": 8.4, "altitude": 112.3, "altitude_accuracy": 4.0, "speed": 6.7, "speed_accuracy": 0.5, "heading": 184.2, "heading_accuracy": 3.0 }, "activity": { "type": "in_vehicle", "confidence": 92 }, "battery": { "level": 0.63, "is_charging": false }, "is_moving": true, "extras": { "fleetId": "north-42" } }}Field-for-field this is a Location
record — see that page for the full type (age, sample, event are
optional and omitted above since this is a plain tracked fix). Note the wire
payload is still snake_case (altitude_accuracy, is_moving) even though
the Swift Location/Coords structs expose the same fields as camelCase
(altitudeAccuracy, isMoving) — the SDK maps between the two internally.
One field your server must be ready for: is_moving can be null (not just
true/false) on a cold start’s first fixes — fall back to coords.speed
and treat null as “not moving”; see
Location for why.
Batching
Enable batchSync to send
several queued records in one request instead of one request per record:
try await BackgroundGeolocation.ready( Config( url: "https://your-server.example/locations", batchSync: true, maxBatchSize: 100, autoSyncThreshold: 25 // only fire once ≥ 25 records are queued ))The body key becomes an array — the same httpRootProperty key, now holding
Location[]:
{ "location": [ { "uuid": "3f9c9e2a-…", "timestamp": "2026-07-24T09:14:32.118Z", "coords": { "…": "…" }, "is_moving": true }, { "uuid": "7a1d4b6e-…", "timestamp": "2026-07-24T09:14:47.902Z", "coords": { "…": "…" }, "is_moving": true } ]}maxBatchSize caps how many
records one request carries — see the
Config reference for the
exact default (unlimited unless set, i.e. every pending record in one batch).
Shaping the body
Three config keys reshape the request without touching the record contents:
params— aMap<String, dynamic>merged into the request body root, alongside the location payload. Useful for constant fields your backend expects on every call (a device ID, an API version).extras— aMap<String, dynamic>merged into every uploaded record’s ownextrasobject. A per-callextras(e.g. one passed toinsertLocation()) wins over this on key conflicts.httpRootProperty— the key carrying the location(s); default"location". Set it to"."to merge a single record’s fields directly into the body root instead of nesting them. This only applies when exactly one record is being sent andbatchSyncis unset orfalse— withbatchSync: true, even a lone queued record still ships as a one-element array under the root-property key ({ "location": [{…}] }), since there’s no array to merge into the root. A multi-record batch always nests under a real key regardless of this setting.
try await BackgroundGeolocation.ready( Config( url: "https://your-server.example/locations", params: ["deviceId": "dev-882", "apiVersion": 2], extras: ["fleetId": "north-42"] ))produces:
{ "deviceId": "dev-882", "apiVersion": 2, "location": { "uuid": "3f9c9e2a-…", "…": "…", "extras": { "fleetId": "north-42" } }}params landed at the root next to location; extras landed inside
the record. With httpRootProperty: "." instead, a single record merges
straight into the root — there is no "location" key at all:
try await BackgroundGeolocation.ready( Config( url: "https://your-server.example/locations", httpRootProperty: "." )){ "uuid": "3f9c9e2a-…", "timestamp": "2026-07-24T09:14:32.118Z", "odometer": 18420.5, "coords": { "…": "…" }, "is_moving": true}Sync behavior
autoSync(defaulttrue) — attempts an upload whenever a new record is queued, subject to the two keys below. Set itfalseto queue everything and drain only on your own explicitsync()calls.autoSyncThreshold— the queue depth that triggers an auto-sync; see the Config reference for the exact default. Unset/0fires on every newly-queued record; raising it defers auto-sync until at least that many records are pending, pairing naturally withbatchSync.disableAutoSyncOnCellular(defaultfalse) — defers auto-sync while the active network is cellular. The drain trigger on Wi-Fi/ethernet arrival is level-triggered, not edge-only: it re-checks on every network-capability change while a non-cellular transport is active, so a missed transition can’t strand the queue. An explicitsync()call always uploads regardless of network type — this key only gates the automatic path.
Manual queue control
let drained = try await BackgroundGeolocation.sync() // drain nowlet queued = await BackgroundGeolocation.getLocations() // inspect, oldest-firstlet count = await BackgroundGeolocation.getCount() // queue depthtry await BackgroundGeolocation.destroyLocation(uuid: queued[0].uuid)_ = await BackgroundGeolocation.destroyLocations() // clear the queueawait BackgroundGeolocation.insertLocation([ "uuid": "manual-checkin-1", "timestamp": ISO8601DateFormatter().string(from: Date()), "coords": ["latitude": 52.23, "longitude": 21.01, "accuracy": 5],])sync() resolves with a
List<Location> snapshot of the queue taken before the drain starts.
getLocations() and
getCount() inspect the
queue without touching it. destroyLocation()
and destroyLocations()
remove one record or the whole queue. insertLocation()
takes a plain Map<String, dynamic> and enqueues it through the normal
persist path — uuid dedup applies, and autoSync fires for it exactly
like a tracked fix.
No url? BGeo runs in persist-only mode — locations are queued and
delivered through onLocation; call sync()/getLocations() yourself.
Retry & failure handling
A failed upload backs off exponentially before the next attempt: 5 seconds
after the first failure, doubling on each consecutive failure (10 s, 20 s,
40 s, …), capped at 5 minutes — httpTimeoutMs
bounds the connect/read/write time of each attempt (see the
Config reference for its
default). Within that, the response status decides what happens to the batch:
| Status | Outcome | Why |
|---|---|---|
2xx | Success — record(s) deleted from the queue. | Server confirmed ingest. |
400, 404, 410, 413, 422 | Drop (poison). | Permanent client errors — malformed body, gone endpoint, oversized payload, unprocessable data. Retrying can’t fix these, so a single bad record can’t wedge the queue behind it forever. A batch that drops re-sends one record at a time afterward, so only the actual poison record is dropped rather than the whole batch. |
429 | Retry with backoff. | Carved out of the poison range deliberately — throttling means the batch itself was fine, so it’s retried like a 5xx. |
401 / 403 | Refresh-and-retry, or retry if no authorization is configured / the refresh itself fails. | See Authorization (JWT refresh) below — these two statuses are intercepted before the drop/retry classification above. |
| any other non-2xx, or no response (network error) | Retry with backoff. | Treated as transient. |
Every completed location-sync request (not log uploads)
fires onHttp — useful for
watching this behavior directly:
let subscription = BackgroundGeolocation.onHttp { event in if !event.success { print("upload failed", event.status, event.responseText) }}HttpEvent.status is 0 for a network error, and responseText is
truncated to 1024 characters. A 401/403 that triggers a token refresh
produces two onHttp events — one for the failed original request, one
for the retried request.
Authorization (JWT refresh)
If your endpoint uses bearer tokens that expire,
authorization — an
Authorization value object — lets the native uploader refresh them on a
401/403 inside the engine, with none of your code running — so a
queue draining while the app is killed or suspended doesn’t stall on an
expired token until the user happens to reopen the app:
try await BackgroundGeolocation.ready( Config( url: "https://your-server.example/locations", authorization: AuthorizationConfig( strategy: "JWT", accessToken: "eyJhbGciOi...", refreshToken: "a1b2c3...", refreshUrl: "https://your-server.example/device/auth/refresh", // Body sent to refreshUrl; the literal "{refreshToken}" is // substituted. Default when unset: {"refresh_token": "<token>"}. refreshPayload: ["refresh_token": "{refreshToken}"], refreshHeaders: ["X-Api-Key": "…"] ) ))
_ = BackgroundGeolocation.onAuthorization { event in if event["success"] as? Bool == true { // Adopt the rotated pair in your own persisted copy. Do not log this // object — it carries live credentials. } else { print("token refresh failed") }}Here’s the killed-app flow end to end — a location upload hits a 401,
the native uploader refreshes on its own, and the same batch
is retried:
sequenceDiagram
participant U as Native uploader
participant S as Your server
U->>S: POST url (queued locations, Bearer accessToken)
S-->>U: 401
U->>S: POST refreshUrl<br/>body: refreshPayload with {refreshToken} substituted
alt refresh succeeds
S-->>U: 200 access_token | accessToken | token,<br/>refresh_token | refreshToken,<br/>optionally nested under "data"
U->>U: rotate tokens, fire onAuthorizationConfig({success:true})
U->>S: retry original POST url with new Bearer token
S-->>U: 2xx
U->>U: delete uploaded records from queue
else refresh fails
S-->>U: non-2xx
U->>U: fire onAuthorizationConfig({success:false, status})
U->>U: classify original 401 as RETRY — batch stays queued for the next backoff pass
end
Note over U: On next app foreground, call getAuthState()<br/>to reconcile the (possibly rotated) tokens.
The refresh response is parsed tolerantly — the access token is read from
whichever of access_token, accessToken, or token is present first, and
the refresh token from refresh_token or refreshToken (falling back to the
existing refresh token if the response doesn’t rotate it), optionally nested
under a data key — so it works against a variety of auth backends without a
custom adapter. Only a genuinely new, non-empty refresh token overwrites the
one the native side holds, so a stale config push can’t clobber a token
already rotated while the app was killed.
Refresh outcomes always surface via onAuthorization
as an AuthorizationEvent
(success, and on failure status — the HTTP status the refresh request
itself received; raw carries the untouched native payload). Because the
tokens can rotate while your process is dead, prefer
getAuthState() over your
app’s own persisted copy when reconciling state on foreground:
let auth = await BackgroundGeolocation.getAuthState()let accessToken = auth.accessTokenlet refreshToken = auth.refreshTokenServer-side quick reference
- Return a 2xx to confirm ingest — the uploaded record(s) are deleted from the queue and the failure counter resets.
- For location uploads,
400/404/410/413/422are treated as permanent and the record(s) are dropped, not retried — don’t use these for transient conditions.429is retried, and401/403trigger the refresh flow above rather than an immediate drop or retry. Any other non-2xx (typically5xx) is retried with backoff. - Body shape recap: default is a single object,
{ "location": {…} };batchSyncsends{ "location": [{…}, {…}] }(even a batch of one record stays an array);httpRootProperty: "."merges a single record’s fields directly into the request root instead — only whenbatchSyncis unset orfalse.