Skip to content

HTTP upload & authorization

BGeo uploads locations natively — a durable, SQLite-backed queue (bgeo.db) drains to your server without any Dart isolate 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:

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 Dart 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 Dart Location/Coords classes 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:

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 — a Map<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 — a Map<String, dynamic> merged into every uploaded record’s own extras object. A per-call extras (e.g. one passed to insertLocation()) 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 and batchSync is unset or false — with batchSync: 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.
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:

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 (default true) — attempts an upload whenever a new record is queued, subject to the two keys below. Set it false to queue everything and drain only on your own explicit sync() calls.
  • autoSyncThreshold — the queue depth that triggers an auto-sync; see the Config reference for the exact default. Unset/0 fires on every newly-queued record; raising it defers auto-sync until at least that many records are pending, pairing naturally with batchSync.
  • disableAutoSyncOnCellular (default false) — 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 explicit sync() call always uploads regardless of network type — this key only gates the automatic path.

Manual queue control

final drained = await BackgroundGeolocation.sync(); // drain now
final queued = await BackgroundGeolocation.getLocations(); // inspect, oldest-first
final count = await BackgroundGeolocation.getCount(); // queue depth
await BackgroundGeolocation.destroyLocation(queued[0].uuid); // remove one
await BackgroundGeolocation.destroyLocations(); // clear the queue
await BackgroundGeolocation.insertLocation({
'uuid': 'manual-checkin-1',
'timestamp': DateTime.now().toUtc().toIso8601String(),
'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 to Dart via 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:

StatusOutcomeWhy
2xxSuccess — record(s) deleted from the queue.Server confirmed ingest.
400, 404, 410, 413, 422Drop (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.
429Retry with backoff.Carved out of the poison range deliberately — throttling means the batch itself was fine, so it’s retried like a 5xx.
401 / 403Refresh-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:

final sub = BackgroundGeolocation.onHttp((event) {
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 on the native side, with no Dart isolate required — 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:

await BackgroundGeolocation.ready(Config(
url: 'https://your-server.example/locations',
authorization: Authorization(
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: '<refreshToken>' }
refreshPayload: {'refresh_token': '{refreshToken}'},
refreshHeaders: {'X-Api-Key': '…'},
),
));
BackgroundGeolocation.onAuthorization((event) {
if (event.success) {
// adopt event.accessToken / event.refreshToken in your own persisted copy
} else {
print('token refresh failed ${event.status}');
}
});

Here’s the killed-app flow end to end — a location upload hits a 401, the native uploader refreshes without any Dart involved, 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 onAuthorization({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 onAuthorization({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 Dart isolate isn’t running, prefer getAuthState() over your app’s own persisted copy when reconciling state on foreground:

final auth = await BackgroundGeolocation.getAuthState();
final accessToken = auth.accessToken;
final refreshToken = auth.refreshToken;

Server-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 / 422 are treated as permanent and the record(s) are dropped, not retried — don’t use these for transient conditions. 429 is retried, and 401/403 trigger the refresh flow above rather than an immediate drop or retry. Any other non-2xx (typically 5xx) is retried with backoff.
  • Body shape recap: default is a single object, { "location": {…} }; batchSync sends { "location": [{…}, {…}] } (even a batch of one record stays an array); httpRootProperty: "." merges a single record’s fields directly into the request root instead — only when batchSync is unset or false.