Receiving uploads on your server
Long Ping posts to your own endpoint. There is no hosted backend and no fixed schema on your side: you get plain JSON over HTTP(S), and you answer with a status code (and, if you like, new settings for the device).
What arrives
Section titled “What arrives”With the defaults (batch: true, no template) every request looks like this:
POST /locations HTTP/1.1Content-Type: application/jsonAccept: application/jsonAuthorization: Bearer <access token>
{ "locations": [ { "uuid": "6f1c2a1e-8b9e-4c1a-9a43-0d6c1d1a7f10", "timestamp": "2026-10-26T12:00:00.123Z", "event": "interval", "is_moving": true, "coords": { "latitude": 43.85, "longitude": 18.41, "accuracy": 4.8, "speed": 12.3, "heading": 181, "altitude": 520.4 }, "battery": { "level": 0.82, "is_charging": false }, "mock": false } ]}- Up to
maxBatchSize(100) records per request, oldest first. methodcan bePUTinstead ofPOST. Yourhttp.headersare sent too; a stored access token addsAuthorization: Bearer ….- Every field is described in Location record.
Try the options
Section titled “Try the options”Change the body shape with rootProperty, params, extras and locationTemplate, and see what your server
gets. Pick a reply to see what the device does with it.
Mode
http.batch: true · up to maxBatchSize records per request
Tokens become raw JSON values. Strings go in without quotes, so write"{{timestamp}}" yourself.
Your server receives
Pretty-printed here; the device sends compact JSON.
You reply
The body rules, exactly:
| Mode | Body |
|---|---|
batch: true |
{ ...params, [rootProperty ?? 'locations']: [r1, r2, …] } |
batch: false |
{ ...params, [rootProperty ?? 'location']: r }, one request per record |
batch: false, rootProperty: '.' |
{ ...params, ...r }: the record’s fields at the body root |
Each r is the record JSON, or your rendered locationTemplate. '.' only applies to single mode; with
batching on it falls back to locations.
Deduplicate by uuid
Section titled “Deduplicate by uuid”Every record has a uuid generated on the device when the fix is recorded. Retries can resend records you
already stored: for example, your server saved the batch but the reply was lost to a timeout. Make uuid a
unique key and ignore duplicates (ON CONFLICT DO NOTHING, INSERT OR IGNORE, insertOrIgnore). All three
examples below do this.
Order by timestamp, not by arrival. Each device sends its queue oldest first, but a backlog that waited out
a dead zone arrives after newer data from other devices, and a fix taken with
getCurrentPosition({ persist: true }) (event current) can fall between interval fixes.
How to reply
Section titled “How to reply”| You answer | The device |
|---|---|
| any 2xx | Deletes the sent records. Reads long_ping from a JSON body, if present. |
| 401 or 403 | Refreshes the token if it can and retries once; otherwise pauses uploads. Tracking continues. |
| 413 | Retries straight away with half the batch. |
| 408, 429, 5xx, or no answer | Keeps the records and backs off; Retry-After (seconds) wins. |
| any other 4xx | Counts a failed attempt for each sent record; drops a record after 3 attempts. |
Answer quickly and do heavy work (geocoding, ETA updates) after replying or in a job queue. The device waits
up to http.timeoutSec (30 s) per request, and only one upload runs at a time.
The full table, with backoff timings, is in the HTTP contract. To change device settings from your reply, see Remote config & remote stop.
Complete examples
Section titled “Complete examples”Each example accepts the default batch format, checks a bearer token, stores records with uuid as the
primary key, ignores duplicates and answers 2xx. They expect this config on the device:
await LongPing.configure({ http: { url: 'https://api.example.com/locations', params: { device_id: 'van-07' }, // arrives at the body root },});await LongPing.setAuthorization({ accessToken: token });// npm install express (node:sqlite is built into Node 22.13+)import express from 'express';import { DatabaseSync } from 'node:sqlite';
const db = new DatabaseSync('locations.db');db.exec(` CREATE TABLE IF NOT EXISTS locations ( uuid TEXT PRIMARY KEY, -- retries may resend a record device_id TEXT NOT NULL, recorded_at TEXT NOT NULL, -- fix time, UTC event TEXT NOT NULL, is_moving INTEGER NOT NULL, lat REAL NOT NULL, lng REAL NOT NULL, accuracy REAL, speed REAL, battery REAL, record TEXT NOT NULL -- the full record as received )`);
const insert = db.prepare(` INSERT OR IGNORE INTO locations (uuid, device_id, recorded_at, event, is_moving, lat, lng, accuracy, speed, battery, record) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
const app = express();app.use(express.json({ limit: '2mb' }));
app.post('/locations', (req, res) => { if (req.get('authorization') !== `Bearer ${process.env.API_TOKEN}`) { return res.status(401).json({ error: 'unauthorized' }); // device refreshes or pauses uploads }
const deviceId = String(req.body.device_id ?? 'unknown'); const records = Array.isArray(req.body.locations) ? req.body.locations : [];
db.exec('BEGIN'); try { for (const r of records) { if (!r?.uuid || !r.coords) continue; // log it; don't fail the whole batch insert.run( r.uuid, deviceId, r.timestamp, r.event, r.is_moving ? 1 : 0, r.coords.latitude, r.coords.longitude, r.coords.accuracy, r.coords.speed, r.battery?.level ?? null, JSON.stringify(r), ); } db.exec('COMMIT'); } catch (err) { db.exec('ROLLBACK'); console.error(err); return res.status(500).json({ error: 'storage' }); // device keeps the records and retries }
res.json({}); // any 2xx = delivered});
app.listen(3000, () => console.log('listening on :3000'));Migration:
<?php
use Illuminate\Database\Migrations\Migration;use Illuminate\Database\Schema\Blueprint;use Illuminate\Support\Facades\Schema;
return new class extends Migration { public function up(): void { Schema::create('locations', function (Blueprint $table) { $table->uuid('uuid')->primary(); // retries may resend a record $table->foreignId('user_id')->constrained(); $table->string('device_id')->nullable(); $table->timestamp('recorded_at', 3); // fix time, UTC $table->string('event', 16); $table->boolean('is_moving'); $table->double('lat'); $table->double('lng'); $table->float('accuracy')->nullable(); $table->float('speed')->nullable(); $table->float('battery')->nullable(); $table->json('record'); // the full record as received }); }};Route, protected by Sanctum. An invalid token gets a 401, which is exactly what the device expects:
<?php
use Illuminate\Http\Request;use Illuminate\Support\Carbon;use Illuminate\Support\Facades\DB;use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->post('/locations', function (Request $request) { $rows = collect($request->input('locations', [])) ->filter(fn ($r) => is_array($r) && isset($r['uuid'], $r['coords'])) ->map(fn (array $r) => [ 'uuid' => $r['uuid'], 'user_id' => $request->user()->id, 'device_id' => $request->input('device_id'), 'recorded_at' => Carbon::parse($r['timestamp'])->utc(), 'event' => $r['event'], 'is_moving' => (bool) $r['is_moving'], 'lat' => $r['coords']['latitude'], 'lng' => $r['coords']['longitude'], 'accuracy' => $r['coords']['accuracy'] ?? null, 'speed' => $r['coords']['speed'] ?? null, 'battery' => $r['battery']['level'] ?? null, 'record' => json_encode($r), ]);
DB::table('locations')->insertOrIgnore($rows->values()->all());
return response()->noContent(); // 204: any 2xx = delivered});# pip install fastapi uvicorn · run: API_TOKEN=... uvicorn main:appimport jsonimport osimport sqlite3
from fastapi import FastAPI, Header, HTTPException, Responsefrom pydantic import BaseModel
API_TOKEN = os.environ["API_TOKEN"]
db = sqlite3.connect("locations.db", check_same_thread=False)db.execute(""" CREATE TABLE IF NOT EXISTS locations ( uuid TEXT PRIMARY KEY, -- retries may resend a record device_id TEXT, recorded_at TEXT NOT NULL, -- fix time, UTC event TEXT NOT NULL, is_moving INTEGER NOT NULL, lat REAL NOT NULL, lng REAL NOT NULL, accuracy REAL, speed REAL, battery REAL, record TEXT NOT NULL -- the full record as received )""")
class Coords(BaseModel): latitude: float longitude: float accuracy: float = -1 speed: float = -1 heading: float = -1 altitude: float | None = None
class Battery(BaseModel): level: float = -1 is_charging: bool = False
class Location(BaseModel): uuid: str timestamp: str event: str is_moving: bool coords: Coords battery: Battery = Battery() mock: bool = False extras: dict | None = None
class Upload(BaseModel): locations: list[Location] = [] device_id: str | None = None
app = FastAPI()
@app.post("/locations", status_code=204)def receive(body: Upload, authorization: str | None = Header(default=None)): if authorization != f"Bearer {API_TOKEN}": raise HTTPException(status_code=401) # device refreshes or pauses uploads
with db: # one transaction db.executemany( "INSERT OR IGNORE INTO locations VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ ( r.uuid, body.device_id, r.timestamp, r.event, int(r.is_moving), r.coords.latitude, r.coords.longitude, r.coords.accuracy, r.coords.speed, r.battery.level, json.dumps(r.model_dump()), ) for r in body.locations ], ) return Response(status_code=204) # any 2xx = deliveredFastAPI answers 422 when a body does not match the model, and Long Ping counts that as a failed attempt
(records are dropped after 3). Keep the models lenient, as here, or accept list[dict] and check each record
yourself.
Using a template
Section titled “Using a template”If your API already has a format, set http.locationTemplate instead of adapting the server:
http: { url: 'https://api.example.com/v2/positions', rootProperty: 'positions', locationTemplate: '{"id":"{{uuid}}","lat":{{latitude}},"lng":{{longitude}},"at":"{{timestamp}}"}',}Keep {{uuid}} in the template if you want to deduplicate. Tokens become raw values, so string tokens need
quotes in the template. configure() rejects a template that does not render valid JSON with
INVALID_CONFIG.