HTTP upload & authorization
BGeo uploads locations natively — a durable, SQLite-backed queue (bgeo.db)
drains to your server without any JS 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({ url: 'https://your-server.example/locations', method: 'POST', // 'POST' (default) | 'PUT' | 'PATCH' headers: { 'X-Api-Key': '…' }, autoSync: true, // default true});Leaving url unset puts the SDK in
persist-only mode: fixes still queue in SQLite and still reach JS 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). 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({ 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 (default -1, unlimited — every pending record
in one batch).
Shaping the body
Three config keys reshape the request without touching the record contents:
params— 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— 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.
await BackgroundGeolocation.ready({ 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({ 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(default0) — the queue depth that triggers an auto-sync.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
const drained = await BackgroundGeolocation.sync(); // drain nowconst queued = await BackgroundGeolocation.getLocations(); // inspect, oldest-firstconst count = await BackgroundGeolocation.getCount(); // queue depthawait BackgroundGeolocation.destroyLocation(queued[0].uuid); // remove oneawait BackgroundGeolocation.destroyLocations(); // clear the queueawait BackgroundGeolocation.insertLocation({ uuid: 'manual-checkin-1', timestamp: new Date().toISOString(), coords: { latitude: 52.23, longitude: 21.01, accuracy: 5 },});sync() resolves with a 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()
enqueues a record 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 JS 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
(default 30000) bounds the connect/read/write time of each attempt. 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:
const sub = BackgroundGeolocation.onHttp(({ success, status, responseText }) => { if (!success) { console.warn('upload failed', status, responseText); }});status is 0 for a network error, and responseText is truncated to 1024
characters. A 401/403 that triggers a token refresh produces two
events — one for the failed original request, one for the retried request.
Authorization (JWT refresh)
If your endpoint uses bearer tokens that expire, authorization
lets the native uploader refresh them on a 401/403 on the native side,
with no JS context 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({ url: 'https://your-server.example/locations', 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 { console.warn('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 JS 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
(success, and on failure status — the HTTP status the refresh request
itself received). Because the tokens can rotate while your JS context isn’t
running, prefer getAuthState()
over your app’s own persisted copy when reconciling state on foreground:
const { accessToken, refreshToken } = await BackgroundGeolocation.getAuthState();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/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.